Compare commits
22
Commits
@@ -749,7 +749,12 @@ impl AppModel {
|
||||
dialog.connect_response(Some("apply"), move |_, _| {
|
||||
let where_to = match &target {
|
||||
SpeedTestTarget::Global => {
|
||||
// Rebase on the file before the whole-file save (same
|
||||
// discipline as the settings dialog): another writer — the
|
||||
// spawner's window-size persist, a second window's dialog —
|
||||
// may have moved it under this shell's snapshot.
|
||||
let mut s = settings.borrow_mut();
|
||||
*s = Settings::load();
|
||||
s.bitrate_kbps = recommended_kbps;
|
||||
s.save();
|
||||
"the default bitrate".to_string()
|
||||
@@ -765,7 +770,9 @@ impl AppModel {
|
||||
});
|
||||
}
|
||||
dialog.connect_response(Some("apply-global"), move |_, _| {
|
||||
// Rebase on the file first — see the Global arm above.
|
||||
let mut s = settings.borrow_mut();
|
||||
*s = Settings::load();
|
||||
s.bitrate_kbps = recommended_kbps;
|
||||
s.save();
|
||||
toasts.add_toast(adw::Toast::new(&format!(
|
||||
@@ -796,9 +803,7 @@ impl SpeedTestTarget {
|
||||
// Resolved exactly the way a connect resolves it: the one-off pick this test was
|
||||
// started with (a pinned card carries one), else the host's binding.
|
||||
let bound = trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == req.addr && h.port == req.port)
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let reference = match req.profile.as_deref() {
|
||||
Some("") => return SpeedTestTarget::Global,
|
||||
|
||||
@@ -164,9 +164,7 @@ pub fn cli_wake() -> glib::ExitCode {
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.find_by_addr(&addr, port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
if mac.is_empty() {
|
||||
@@ -201,9 +199,7 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
.and_then(crate::trust::parse_hex32)
|
||||
.or_else(|| {
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr)
|
||||
.find_by_addr(&addr, port)
|
||||
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
@@ -343,9 +339,8 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.index_by_addr(&addr, port)
|
||||
.and_then(|i| known.hosts.get_mut(i))
|
||||
{
|
||||
h.name = name;
|
||||
} else {
|
||||
|
||||
@@ -1910,7 +1910,14 @@ pub fn show_scoped(
|
||||
commit_profile(active, &touched, &values);
|
||||
}
|
||||
None => {
|
||||
// Rebase on the file, not the shell's start-of-app snapshot: the settings file
|
||||
// has other whole-file writers (the spawner persists `last_window_w/h` after a
|
||||
// match-window resize — see `profiles.rs` on why there's no merge), and saving
|
||||
// the stale snapshot here would silently revert whatever they stored while this
|
||||
// app was open. The rows carry every value this dialog owns, so applying them
|
||||
// onto a fresh load loses nothing.
|
||||
let mut s = settings.borrow_mut();
|
||||
*s = Settings::load();
|
||||
apply_rows(&mut s);
|
||||
s.save();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ default = ["ui", "pyrowave"]
|
||||
# PyroWave client decode (the wired-LAN wavelet codec) — enables the decode backend + the
|
||||
# planar present path. ON by default; each session still opts in explicitly (the Settings
|
||||
# codec pick, or PUNKTFUNK_PREFER_PYROWAVE=1). The Windows ARM64 leg builds
|
||||
# --no-default-features and so skips it (video decode is Linux-only anyway).
|
||||
# --no-default-features and so skips it — note that also drops `ui` (the Skia console/OSD),
|
||||
# not just pyrowave; hardware video decode itself (D3D11VA / Vulkan Video) is unaffected.
|
||||
pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
|
||||
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
|
||||
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
|
||||
|
||||
@@ -448,9 +448,8 @@ impl ServiceState {
|
||||
// Manual entries have no fingerprint yet, so `upsert` (fp-keyed) would
|
||||
// collide two of them — key manual saves by address instead.
|
||||
if let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.index_by_addr(&addr, port)
|
||||
.and_then(|i| known.hosts.get_mut(i))
|
||||
{
|
||||
if !name.is_empty() {
|
||||
h.name = name;
|
||||
|
||||
+49
-17
@@ -175,10 +175,11 @@ mod session_main {
|
||||
// the last store read the compat path still owes. `addr` is moved into the struct
|
||||
// below, so read it first.
|
||||
let clipboard = clipboard_override.unwrap_or_else(|| {
|
||||
// The record this address RESOLVES to, not "any record mentioning it": a retired
|
||||
// duplicate must never be the one that hands a host the clipboard.
|
||||
trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
|
||||
.find_by_addr(&addr, port)
|
||||
.is_some_and(|h| h.clipboard_sync)
|
||||
});
|
||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||
@@ -218,6 +219,8 @@ mod session_main {
|
||||
height: sh,
|
||||
..mode
|
||||
};
|
||||
// Before the struct literal — `vulkan` moves into it below.
|
||||
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
@@ -248,9 +251,14 @@ mod session_main {
|
||||
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
|
||||
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
|
||||
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
||||
// Welcome BEFORE we build a decoder. Advertised unconditionally when the user asks
|
||||
// for it because every decode path here can produce it: the hardware ones where the
|
||||
// driver decodes RExt, and swscale for the rest (the decoder demotes on its own).
|
||||
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
|
||||
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
|
||||
// formats (hardware RExt decode where the driver offers it — NVIDIA today) and
|
||||
// swscale converts anything else for the software rung, with the decoder ladder
|
||||
// demoting on its own. No capability probe gates the bit — software decode is the
|
||||
// guaranteed floor — but the cost is VISIBLE, not silent: the Detailed stats
|
||||
// overlay prints the resolved chroma ("4:4:4→4:2:0" when the host declined) and
|
||||
// the decode path frames actually took.
|
||||
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
||||
| if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
@@ -262,9 +270,19 @@ mod session_main {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||
// pump) pins one manually.
|
||||
// This panel's HDR colour volume → the host's virtual-display EDID, so host
|
||||
// apps tone-map to the real glass. Windows reads it from DXGI (the
|
||||
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
|
||||
// setting, since with 10-bit/HDR unadvertised above the volume is noise. No
|
||||
// portable Wayland/X11 query exists yet, so Linux keeps the host's EDID
|
||||
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
|
||||
// manually on either OS and wins over both.
|
||||
#[cfg(windows)]
|
||||
display_hdr: settings
|
||||
.hdr_enabled
|
||||
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
|
||||
.flatten(),
|
||||
#[cfg(not(windows))]
|
||||
display_hdr: None,
|
||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
||||
@@ -284,6 +302,13 @@ mod session_main {
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
|
||||
// advertised only when the presenter has real on-glass latch stamps
|
||||
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
|
||||
// grid itself is written by the presenter (run_session clones the Arc out of
|
||||
// these params) and folded into ~1 Hz PhaseReports by the session pump.
|
||||
phase_lock,
|
||||
latch_grid: std::sync::Arc::new(pf_client_core::session::LatchGrid::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,11 +463,21 @@ mod session_main {
|
||||
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||
// the RADV knob (covers --connect and --browse).
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names /
|
||||
// WASAPI endpoint ids) for the playback/mic streams. Before any Vulkan call,
|
||||
// like the RADV knob (covers --connect and --browse).
|
||||
//
|
||||
// Spec mode takes them from the SPEC's settings — the spawner's resolve — which
|
||||
// keeps the §5 zero-store-reads invariant and lets a profile overlay reach these
|
||||
// fields if they ever become profileable. Parsed leniently here (the `--connect`
|
||||
// flow re-reads the spec authoritatively and errors there); the compat path and
|
||||
// `--browse` (which never carries a spec) still load the store.
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
let s = arg_value("--resolved-spec")
|
||||
.and_then(|p| {
|
||||
pf_client_core::orchestrate::ResolvedSpec::read(std::path::Path::new(&p)).ok()
|
||||
})
|
||||
.map_or_else(trust::Settings::load, |spec| spec.settings);
|
||||
for (var, value) in [
|
||||
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
||||
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
||||
@@ -540,10 +575,7 @@ mod session_main {
|
||||
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
|
||||
// silent TOFU would defeat the pinning model. Pair via the desktop client.
|
||||
let known = trust::KnownHosts::load();
|
||||
let known_host = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let known_host = known.find_by_addr(&addr, port);
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(trust::parse_hex32)
|
||||
|
||||
@@ -80,14 +80,42 @@ fn initiate_opts(
|
||||
}
|
||||
|
||||
/// Start a stream that launches a library title on connect (`--launch id`) — the library
|
||||
/// page's tap-to-play. The library only opens for paired hosts, so the pin resolves like
|
||||
/// a normal initiate; a host forgotten mid-visit routes to the PIN ceremony instead.
|
||||
/// page's tap-to-play, and a deep link's `launch=` (which this used to drop: the link
|
||||
/// opened a plain desktop session). The library only opens for paired hosts, so the pin
|
||||
/// resolves like a normal initiate; a host forgotten mid-visit routes to the PIN ceremony
|
||||
/// instead.
|
||||
pub(crate) fn initiate_launch(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
launch: String,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
initiate_launch_opts(ctx, target, launch, set_screen, set_status, false)
|
||||
}
|
||||
|
||||
/// [`initiate_launch`] with the dial-first wake of [`initiate_waking`] — a deep link's
|
||||
/// `launch=` toward a saved host that isn't advertising but has a known MAC.
|
||||
pub(crate) fn initiate_launch_waking(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
launch: String,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
if ctx.settings.lock().unwrap().auto_wake {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
initiate_launch_opts(ctx, target, launch, set_screen, set_status, true)
|
||||
}
|
||||
|
||||
fn initiate_launch_opts(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
launch: String,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
wake_on_fail: bool,
|
||||
) {
|
||||
*ctx.shared.target.lock().unwrap() = target.clone();
|
||||
let known = KnownHosts::load();
|
||||
@@ -112,6 +140,7 @@ pub(crate) fn initiate_launch(
|
||||
set_status,
|
||||
ConnectOpts {
|
||||
launch: Some(launch),
|
||||
wake_on_fail,
|
||||
..ConnectOpts::default()
|
||||
},
|
||||
);
|
||||
@@ -222,16 +251,6 @@ fn connect_spawn(
|
||||
*ctx.shared.session.lock().unwrap() = child.clone();
|
||||
ctx.shared.stats_line.lock().unwrap().clear();
|
||||
ctx.shared.browse.store(false, Ordering::SeqCst);
|
||||
// Through the same resolver the session uses, not the raw globals: "Start streams
|
||||
// fullscreen" is a profileable (tier-P) field, so a host bound to a windowed profile has to
|
||||
// win here too — the child takes this decision from the argv, not from its own settings.
|
||||
let fullscreen = pf_client_core::trust::effective_settings(
|
||||
&target.addr,
|
||||
target.port,
|
||||
target.profile.as_deref(),
|
||||
)
|
||||
.0
|
||||
.fullscreen_on_stream;
|
||||
set_status.call(String::new());
|
||||
set_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
@@ -249,13 +268,15 @@ fn connect_spawn(
|
||||
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
|
||||
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
|
||||
let profile_arg = target.profile.clone();
|
||||
// The launch id: an explicit opts pick (the library's tap-to-play), else one riding
|
||||
// the target — a deep link's `launch=` that detoured through the PIN ceremony.
|
||||
let launch_arg = opts.launch.clone().or_else(|| target.launch.clone());
|
||||
let spawned = crate::spawn::spawn_session(
|
||||
&addr,
|
||||
port,
|
||||
&fp_arg,
|
||||
opts.connect_timeout.as_secs(),
|
||||
fullscreen,
|
||||
opts.launch.as_deref(),
|
||||
launch_arg.as_deref(),
|
||||
profile_arg.as_deref(),
|
||||
child,
|
||||
move |event| {
|
||||
@@ -277,8 +298,10 @@ fn connect_spawn(
|
||||
// host PAIRED so future connects are silent. Plain TOFU persists
|
||||
// it *unpaired* (pinned): the child connected pinned to the
|
||||
// advertised fingerprint, so ready proves the host holds it.
|
||||
// Either way an authorised decision, so `upsert_trusted`: a dead
|
||||
// record for this address is retired instead of shadowing this one.
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
@@ -502,6 +525,8 @@ fn wake_and_connect(
|
||||
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
|
||||
// host so the pin stays reachable next time (keyed by fingerprint;
|
||||
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
|
||||
// Plain `upsert` on purpose — this is an mDNS advert talking, not a trust
|
||||
// decision, so it may never retire another saved host at that address.
|
||||
if let Some((addr, port)) =
|
||||
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
|
||||
{
|
||||
|
||||
@@ -670,6 +670,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
|
||||
// covers a routed/Tailscale host that never advertises — the display companion to
|
||||
@@ -959,6 +960,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
profile: None,
|
||||
launch: None,
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let (badge, kind) = if h.pair == "required" {
|
||||
@@ -1052,6 +1054,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
launch: None,
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
|
||||
@@ -107,6 +107,10 @@ pub(crate) struct Target {
|
||||
/// `None` honors the binding. It never rebinds anything — the default changes only through
|
||||
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
|
||||
pub(crate) profile: Option<String>,
|
||||
/// A library title id (`steam:570`, …) to launch on connect — carried on the target so it
|
||||
/// survives a detour through the PIN ceremony (a deep link's `launch=` toward an unpaired
|
||||
/// host must still launch the game once pairing succeeds).
|
||||
pub(crate) launch: Option<String>,
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
@@ -392,22 +396,54 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
pair_optional: false,
|
||||
mac: p.host.mac.clone(),
|
||||
profile: p.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
// With a MAC it takes the dial first wake path, so a sleeping host wakes
|
||||
// instead of erroring — exactly what clicking its tile would do.
|
||||
if p.wake && !target.mac.is_empty() {
|
||||
connect::initiate_waking(&ctx, target, &set_screen, &set_status);
|
||||
} else {
|
||||
connect::initiate(&ctx, target, &set_screen, &set_status);
|
||||
// instead of erroring — exactly what clicking its tile would do. The
|
||||
// link's `launch=` id must reach the session (`--launch`) — this arm used
|
||||
// to drop it, so a game link opened a plain desktop session.
|
||||
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
|
||||
(Some(id), true) => {
|
||||
connect::initiate_launch_waking(
|
||||
&ctx,
|
||||
target,
|
||||
id,
|
||||
&set_screen,
|
||||
&set_status,
|
||||
);
|
||||
}
|
||||
(Some(id), false) => {
|
||||
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
|
||||
}
|
||||
(None, true) => {
|
||||
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
|
||||
}
|
||||
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
|
||||
}
|
||||
}
|
||||
// Known but never pinned, or not known at all: a link may not pair or trust on
|
||||
// its own, so it lands on the host list with the reason shown. The user pairs
|
||||
// there, under their own eyes.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => refuse(format!(
|
||||
"{} isn't paired with this device yet \u{2014} pair it, then use the link again.",
|
||||
u.name.clone().unwrap_or_else(|| u.addr.clone())
|
||||
)),
|
||||
// Known but never pinned, or not known at all: a link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
|
||||
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
|
||||
// the first connect is verified against it rather than blind TOFU, and the
|
||||
// launch/profile surviving the detour (§3.1; GTK-shell parity — this used to
|
||||
// refuse outright and make shared links a dead end on Windows).
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
let name = u.name.clone().unwrap_or_else(|| u.addr.clone());
|
||||
*ctx.shared.target.lock().unwrap() = Target {
|
||||
name: name.clone(),
|
||||
addr: u.addr.clone(),
|
||||
port: u.port,
|
||||
fp_hex: u.fp.clone(),
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
profile: u.profile.clone(),
|
||||
launch: u.launch.clone(),
|
||||
};
|
||||
set_status.call(format!(
|
||||
"{name} isn't paired with this device yet \u{2014} pair it to continue."
|
||||
));
|
||||
set_screen.call(Screen::Pair);
|
||||
}
|
||||
Ok(PlanOutcome::Unsupported(route)) => refuse(format!(
|
||||
"Punktfunk can't open \u{201c}{}\u{201d} links yet.",
|
||||
route.as_str()
|
||||
|
||||
@@ -50,8 +50,10 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
// The PIN ceremony is an authorised trust decision, so this also
|
||||
// retires a dead record for the same address (a re-keyed host).
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: target3.name.clone(),
|
||||
addr: target3.addr.clone(),
|
||||
port: target3.port,
|
||||
|
||||
@@ -75,6 +75,9 @@ const GAMEPADS: &[(&str, &str)] = &[
|
||||
("dualsense", "DualSense"),
|
||||
("xboxone", "Xbox One"),
|
||||
("dualshock4", "DualShock 4"),
|
||||
// Kept in lockstep with the GTK picker: this row was missing here, so a Windows
|
||||
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||
@@ -393,7 +396,15 @@ fn commit(
|
||||
edit: impl FnOnce(&mut Settings),
|
||||
) {
|
||||
if scope.is_empty() {
|
||||
// Rebase on the file before the whole-struct save: the process-lifetime snapshot
|
||||
// in `ctx.settings` is not the only writer — a spawned session persists its
|
||||
// match-window size, the console's own settings screen saves too — and saving the
|
||||
// stale snapshot would silently revert whatever they stored (the same
|
||||
// load-modify-save family as the GTK dialog's 2026-07-31 fix; profiles.rs
|
||||
// documents why there's no merge). The edit lands on the fresh load, and the
|
||||
// snapshot follows so every row keeps rendering what's on disk.
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
*s = Settings::load();
|
||||
edit(&mut s);
|
||||
s.save();
|
||||
rev.1.call(rev.0 + 1);
|
||||
@@ -875,9 +886,12 @@ pub(crate) fn settings_page(
|
||||
keys.get(sel - 1).cloned()
|
||||
};
|
||||
// Apply live to the gamepad service and persist — the spawned session
|
||||
// reads `forward_pad` at connect.
|
||||
// reads `forward_pad` at connect. Rebase on the file first (the same
|
||||
// discipline as `commit()`): this handler bypasses commit and a stale
|
||||
// whole-struct save would revert other writers.
|
||||
svc.set_pinned(key.clone());
|
||||
let mut s = ctx2.settings.lock().unwrap();
|
||||
*s = Settings::load();
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
s.save();
|
||||
})
|
||||
@@ -913,6 +927,33 @@ pub(crate) fn settings_page(
|
||||
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
|
||||
s.mic_enabled = on
|
||||
});
|
||||
// Endpoint pickers (the WASAPI probe — the GTK client's PipeWire twins): visible
|
||||
// labels are friendly names, the stored value is the endpoint id. Hidden when the
|
||||
// probe found at most the default; a saved device that's gone keeps a revertable
|
||||
// "(not detected)" entry, like the GPU row. Device facts — defaults scope only.
|
||||
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
|
||||
let dev_combo = |saved: &str,
|
||||
devs: &[pf_client_core::audio::AudioDevice],
|
||||
apply: fn(&mut Settings, String)| {
|
||||
let mut names = vec!["System default".to_string()];
|
||||
let mut keys = vec![String::new()];
|
||||
for d in devs {
|
||||
names.push(d.description.clone());
|
||||
keys.push(d.name.clone());
|
||||
}
|
||||
if !saved.is_empty() && !keys.iter().any(|k| k == saved) {
|
||||
names.push(format!("{saved} (not detected)"));
|
||||
keys.push(saved.to_string());
|
||||
}
|
||||
(keys.len() > 1).then(|| {
|
||||
let current = keys.iter().position(|k| k == saved).unwrap_or(0);
|
||||
setting_combo(ctx, scope, (rev, set_rev), names, current, move |s, i| {
|
||||
apply(s, keys[i.min(keys.len() - 1)].clone());
|
||||
})
|
||||
})
|
||||
};
|
||||
let speaker_combo = dev_combo(&s.speaker_device, &speakers, |s, v| s.speaker_device = v);
|
||||
let mic_dev_combo = dev_combo(&s.mic_device, &mics, |s, v| s.mic_device = v);
|
||||
|
||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||
let hud_combo = setting_combo(ctx, scope, (rev, set_rev), hud_names, hud_i, |s, i| {
|
||||
@@ -1134,6 +1175,46 @@ pub(crate) fn settings_page(
|
||||
group(
|
||||
None,
|
||||
[
|
||||
// The read-only pad inventory (GTK parity): what THIS device sees right
|
||||
// now — the fastest answer to "is my controller even detected?". A
|
||||
// device fact, so defaults scope only, like the forward picker below.
|
||||
(!profile_mode).then(|| {
|
||||
let inventory: Element = if pads.is_empty() {
|
||||
text_block("No controllers detected")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.into()
|
||||
} else {
|
||||
vstack(
|
||||
pads.iter()
|
||||
.map(|p| {
|
||||
let sub = if p.steam_virtual {
|
||||
"Steam Input's virtual pad \u{2014} Automatic skips \
|
||||
it while a real pad is connected"
|
||||
.to_string()
|
||||
} else {
|
||||
p.kind_label().to_string()
|
||||
};
|
||||
vstack((
|
||||
text_block(p.name.clone()).semibold(),
|
||||
text_block(sub)
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(1.0)
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<Element>>(),
|
||||
)
|
||||
.spacing(8.0)
|
||||
.into()
|
||||
};
|
||||
described_labeled(
|
||||
"Detected controllers",
|
||||
inventory,
|
||||
"Plug in or pair a controller and it appears here.",
|
||||
)
|
||||
}),
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
@@ -1168,8 +1249,8 @@ pub(crate) fn settings_page(
|
||||
"Audio",
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
described_overridable(
|
||||
[
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"audio_channels",
|
||||
@@ -1178,8 +1259,22 @@ pub(crate) fn settings_page(
|
||||
channels_combo,
|
||||
"The speaker layout requested from the host. It downmixes if its own \
|
||||
output has fewer channels.",
|
||||
),
|
||||
described_overridable(
|
||||
)),
|
||||
// The endpoint picks are facts about THIS device's hardware — never
|
||||
// per profile, like Decoder/GPU.
|
||||
(!profile_mode)
|
||||
.then(|| {
|
||||
speaker_combo.map(|c| {
|
||||
described_labeled(
|
||||
"Speaker",
|
||||
c,
|
||||
"Host audio plays here \u{2014} System default follows \
|
||||
the Windows output device.",
|
||||
)
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mic_enabled",
|
||||
@@ -1187,8 +1282,22 @@ pub(crate) fn settings_page(
|
||||
over.mic_enabled,
|
||||
mic_toggle,
|
||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
||||
),
|
||||
],
|
||||
)),
|
||||
(!profile_mode)
|
||||
.then(|| {
|
||||
mic_dev_combo.map(|c| {
|
||||
described_labeled(
|
||||
"Microphone",
|
||||
c,
|
||||
"The input that feeds the host\u{2019}s virtual mic.",
|
||||
)
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Some("Applies from the next session."),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -130,9 +130,7 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
// it: the one-off this test was started with, else the host's binding.
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == target.addr && h.port == target.port)
|
||||
.find_by_addr(&target.addr, target.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let profile = match target.profile.as_deref() {
|
||||
Some("") => None,
|
||||
@@ -140,39 +138,80 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
None => bound,
|
||||
}
|
||||
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
|
||||
let apply_btn = {
|
||||
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
||||
let profile = profile.clone();
|
||||
button(match &profile {
|
||||
Some(p) => format!(
|
||||
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||
p.name
|
||||
),
|
||||
None => format!("Use {recommended_mbps:.0} Mb/s"),
|
||||
})
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
match &profile {
|
||||
Some(p) => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
|
||||
slot.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
let kbps = *recommended_kbps;
|
||||
let write_global = {
|
||||
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||
move || {
|
||||
// Rebase on the file before the whole-struct save — same discipline as
|
||||
// `commit()`; another writer may have moved it under this snapshot.
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
*s = crate::trust::Settings::load();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
};
|
||||
let write_profile = |id: String| {
|
||||
let ss = set_screen.clone();
|
||||
move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == id) {
|
||||
slot.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
}
|
||||
};
|
||||
// Which button(s): no binding → the global; a binding that already overrides
|
||||
// bitrate → that override (it's what this host reads). Bound but INHERITING
|
||||
// bitrate could legitimately mean either layer — offer both rather than
|
||||
// guessing (the GTK client's Ask tier; this shell used to silently CREATE an
|
||||
// override on the profile).
|
||||
let mut buttons: Vec<Element> = Vec::new();
|
||||
match &profile {
|
||||
None => buttons.push(
|
||||
button(format!("Use {recommended_mbps:.0} Mb/s"))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(write_global.clone())
|
||||
.into(),
|
||||
),
|
||||
Some(p) if p.overrides.bitrate_kbps.is_some() => buttons.push(
|
||||
button(format!(
|
||||
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||
p.name
|
||||
))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(write_profile(p.id.clone()))
|
||||
.into(),
|
||||
),
|
||||
Some(p) => {
|
||||
buttons.push(
|
||||
button("Set as default")
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(write_global.clone())
|
||||
.into(),
|
||||
);
|
||||
buttons.push(
|
||||
button(format!("Set in \u{201c}{}\u{201d}", p.name))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(write_profile(p.id.clone()))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
buttons.push({
|
||||
let ss = set_screen.clone();
|
||||
button("Close")
|
||||
.icon(Symbol::Cancel)
|
||||
.on_click(move || ss.call(Screen::Hosts))
|
||||
.into()
|
||||
});
|
||||
let results = card(
|
||||
vstack((
|
||||
text_block(format!("{mbps:.0} Mbit/s"))
|
||||
@@ -190,14 +229,9 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
hstack((apply_btn, {
|
||||
let ss = set_screen.clone();
|
||||
button("Close")
|
||||
.icon(Symbol::Cancel)
|
||||
.on_click(move || ss.call(Screen::Hosts))
|
||||
}))
|
||||
.spacing(8.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
hstack(buttons)
|
||||
.spacing(8.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
))
|
||||
.spacing(12.0),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! Streaming (decode + present) runs in the spawned `punktfunk-session` binary; the shell only
|
||||
//! needs the list of real (hardware) adapters to offer on a multi-GPU box (a hybrid laptop or an
|
||||
//! eGPU). The picked adapter description is persisted (`crate::trust::Settings::adapter`) and read
|
||||
//! by the session child at connect (`PUNKTFUNK_ADAPTER` remains the session binary's env override).
|
||||
//! by the session child at connect (`PUNKTFUNK_VK_ADAPTER` remains the session binary's env
|
||||
//! override).
|
||||
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
@@ -55,9 +55,19 @@ impl SessionChild {
|
||||
/// One parsed stdout line of the session contract; `None` for anything unrecognized.
|
||||
enum ChildLine {
|
||||
Ready,
|
||||
Error { msg: String, trust_rejected: bool },
|
||||
Error {
|
||||
msg: String,
|
||||
trust_rejected: bool,
|
||||
},
|
||||
Ended(String),
|
||||
Stats(String),
|
||||
/// The session window's logical size settled here under match-window — the SPAWNER
|
||||
/// persists it (design/client-architecture-split.md §5). This shell ignored the line
|
||||
/// until 2026-07-31, so its sessions fell back to persisting from the renderer.
|
||||
Window {
|
||||
w: u32,
|
||||
h: u32,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
@@ -77,6 +87,12 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||
return Some(ChildLine::Ended(msg.to_string()));
|
||||
}
|
||||
if let Some(win) = v.get("window") {
|
||||
let dim = |k: &str| win.get(k).and_then(|n| n.as_u64()).map(|n| n as u32);
|
||||
if let (Some(w), Some(h)) = (dim("w"), dim("h")) {
|
||||
return Some(ChildLine::Window { w, h });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -107,42 +123,60 @@ pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
|
||||
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
|
||||
/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start
|
||||
/// streams fullscreen" toggle); `launch` carries a library title id for the host to
|
||||
/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) —
|
||||
/// surfaced as a connect error by the caller.
|
||||
/// can kill it. `launch` carries a library title id for the host to launch during the
|
||||
/// handshake; `profile` is a ONE-OFF settings-profile pick. `Err` = the spawn itself
|
||||
/// failed (binary missing?) — surfaced as a connect error by the caller.
|
||||
///
|
||||
/// The argv and the `--resolved-spec` both come from the shared brain
|
||||
/// ([`ConnectPlan::for_target`] → `session_args()` + `spec()`): this shell hand-assembled
|
||||
/// its argv until 2026-07-31, which meant no spec — its sessions took the compat path and
|
||||
/// re-resolved every setting from the stores (the drift `orchestrate.rs` documents as a
|
||||
/// trap), and any field added to the spec was silently Windows-dead. Fullscreen now also
|
||||
/// comes from the plan's EFFECTIVE settings (profile-aware) instead of a caller argument.
|
||||
#[allow(clippy::too_many_arguments)] // one cohesive spawn spec (session_params precedent)
|
||||
pub(crate) fn spawn_session(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: &str,
|
||||
connect_timeout_secs: u64,
|
||||
fullscreen: bool,
|
||||
launch: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
use pf_client_core::orchestrate::{ConnectPlan, HostTarget};
|
||||
let mut plan = ConnectPlan::for_target(
|
||||
HostTarget {
|
||||
name: String::new(), // display-only; this shell's screens carry their own copy
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
|
||||
id: None,
|
||||
},
|
||||
launch.map(str::to_string),
|
||||
profile.map(str::to_string),
|
||||
);
|
||||
plan.connect_timeout_secs = Some(connect_timeout_secs);
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--connect")
|
||||
.arg(format!("{addr}:{port}"))
|
||||
.arg("--fp")
|
||||
.arg(fp_hex)
|
||||
.arg("--connect-timeout")
|
||||
.arg(connect_timeout_secs.to_string());
|
||||
if fullscreen {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
if let Some(id) = launch {
|
||||
cmd.arg("--launch").arg(id);
|
||||
}
|
||||
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
|
||||
// binding through the same helper this shell would have used, so the two can't disagree.
|
||||
if let Some(reference) = profile {
|
||||
cmd.arg("--profile").arg(reference);
|
||||
}
|
||||
let mut args = plan.session_args();
|
||||
// Spec mode (design/client-architecture-split.md §5): the child reads no stores and
|
||||
// cannot disagree with us about a file either of us might write. A spec we fail to
|
||||
// write is not fatal — the compat path resolves the same values via the same helper.
|
||||
let spec_path = match plan.spec(plan.clipboard).write_temp() {
|
||||
Ok(path) => {
|
||||
args.push("--resolved-spec".into());
|
||||
args.push(path.to_string_lossy().into_owned());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "couldn't write the resolved spec; the session will resolve for itself");
|
||||
None
|
||||
}
|
||||
};
|
||||
cmd.args(args);
|
||||
add_window_pos(&mut cmd);
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), spec_path, slot, on_event)
|
||||
}
|
||||
|
||||
/// Spawn the session binary in `--browse` mode: the console (gamepad) library for a
|
||||
@@ -169,7 +203,7 @@ pub(crate) fn spawn_browse(
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
|
||||
spawn_with(cmd, &label, slot, on_event)
|
||||
spawn_with(cmd, &label, None, slot, on_event)
|
||||
}
|
||||
|
||||
/// Hand the shell window's position to the child (`--window-pos`) so the session window
|
||||
@@ -182,9 +216,11 @@ fn add_window_pos(cmd: &mut Command) {
|
||||
}
|
||||
|
||||
/// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`].
|
||||
/// `spec_path` is the child's `--resolved-spec` temp file, deleted once the child exits.
|
||||
fn spawn_with(
|
||||
mut cmd: Command,
|
||||
host_label: &str,
|
||||
spec_path: Option<std::path::PathBuf>,
|
||||
slot: SessionChild,
|
||||
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
@@ -225,9 +261,19 @@ fn spawn_with(
|
||||
trust_rejected,
|
||||
}) => error = Some((msg, trust_rejected)),
|
||||
Some(ChildLine::Ended(msg)) => ended = Some(msg),
|
||||
// The window size is the spawner's to persist — the renderer only
|
||||
// reports it (same handling as orchestrate's own reader).
|
||||
Some(ChildLine::Window { w, h }) => {
|
||||
pf_client_core::orchestrate::persist_window_size(w, h);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// The spec has done its job the moment the child has read it; a leftover temp
|
||||
// file in %TEMP% is litter, and one per launch adds up.
|
||||
if let Some(path) = &spec_path {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
// EOF — reap the child (killed-by-Disconnect lands here too; -1 = no code).
|
||||
let code = slot
|
||||
.0
|
||||
@@ -277,6 +323,12 @@ mod tests {
|
||||
Some(ChildLine::Stats(s)) => assert!(s.starts_with("1280")),
|
||||
_ => panic!("stats line"),
|
||||
}
|
||||
// The match-window report: the SPAWNER persists it (§5) — dropping this line was
|
||||
// why Windows sessions fell back to renderer-local persistence.
|
||||
match parse_line("{\"window\":{\"w\":1600,\"h\":900}}") {
|
||||
Some(ChildLine::Window { w, h }) => assert_eq!((w, h), (1600, 900)),
|
||||
_ => panic!("window line"),
|
||||
}
|
||||
assert!(parse_line("").is_none());
|
||||
assert!(parse_line("{\"other\":1}").is_none());
|
||||
}
|
||||
|
||||
@@ -488,12 +488,15 @@ pub(crate) fn note_hdr_capture_failed(source: HdrSource) {
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
||||
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||
// but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
|
||||
// direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
||||
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
||||
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
||||
// IDD-push delivers full-chroma RGB for a 4:4:4 session — BGRA on an SDR display, packed 10-bit
|
||||
// BT.2020 PQ (`Rgb10a2`) on an HDR one — skipping the subsampling converters entirely. Only a
|
||||
// backend that ingests RGB and CSCs it to 4:4:4 itself can use that: today just direct-NVENC
|
||||
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring).
|
||||
//
|
||||
// The display's HDR state is deliberately NOT part of this answer, and no longer needs to be:
|
||||
// both depths have a full-chroma source now, so the chroma resolved here — before the Welcome —
|
||||
// is the chroma the stream really carries. (It used to be a lie whenever the display was HDR:
|
||||
// this returned true, the Welcome promised 4:4:4, and the capturer then quietly emitted P010.)
|
||||
encoder_ingests_rgb_444
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -44,8 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_USAGE_IMMUTABLE, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
||||
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
||||
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
|
||||
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
|
||||
@@ -362,11 +362,145 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
}
|
||||
";
|
||||
|
||||
/// scRGB FP16 → **R10G10B10A2** (BT.2020 PQ, FULL-range RGB) — one full-res pass, the HDR twin of
|
||||
/// the SDR 4:4:4 BGRA passthrough. Keeps full chroma all the way to the encoder: NVENC ingests the
|
||||
/// packed 10-bit RGB (`NV_ENC_BUFFER_FORMAT_ABGR10`) and CSCs it to YUV **4:4:4** itself under
|
||||
/// FREXT, per the BT.2020/PQ VUI the encoder writes — HEVC Main 4:4:4 10. Without this the HDR
|
||||
/// path had only [`HdrP010Converter`], whose chroma pass subsamples, so a session that negotiated
|
||||
/// 4:4:4 on an HDR display silently fell back to 4:2:0.
|
||||
///
|
||||
/// The colour math is [`HDR_P010_COMMON`]'s `scrgb_to_pq2020` verbatim — the SAME pixels the P010
|
||||
/// luma pass starts from — so the two HDR outputs agree bit-for-bit before quantization. Only the
|
||||
/// destination differs: no RGB→YUV, no studio-range squeeze and no chroma decimation here, just the
|
||||
/// hardware's UNORM quantization of the PQ values into 10 bits per channel.
|
||||
///
|
||||
/// Channel order: DXGI `R10G10B10A2_UNORM` stores R in the low 10 bits, which is exactly what
|
||||
/// NVENC calls `ABGR10` (it names A2B10G10R10 from the MSB down) — the same relationship the SDR
|
||||
/// path relies on between DXGI `B8G8R8A8` and NVENC's `ARGB`. So the shader writes natural RGB
|
||||
/// order and no swizzle is needed.
|
||||
pub(crate) struct HdrRgb10Converter {
|
||||
vs: ID3D11VertexShader,
|
||||
ps: ID3D11PixelShader,
|
||||
sampler: ID3D11SamplerState,
|
||||
}
|
||||
|
||||
/// R10G10B10A2 pass PS — full-res, writes PQ-encoded BT.2020 RGB straight to the packed 10-bit
|
||||
/// target. `saturate` is implicit in the UNORM render target; `scrgb_to_pq2020` already clamps.
|
||||
const HDR_RGB10_PS: &str = r"
|
||||
#include_common
|
||||
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
return float4(scrgb_to_pq2020(uv), 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
impl HdrRgb10Converter {
|
||||
pub(crate) fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader`
|
||||
// receives `s!()` literals (its contract). Each created COM interface owns its own
|
||||
// reference, and no raw pointer outlives the call that produced it.
|
||||
unsafe {
|
||||
let src = HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON);
|
||||
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = compile_shader(&src, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps = None;
|
||||
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
|
||||
// POINT, like the P010 luma pass: this is a 1:1 full-res resample, so every RT pixel
|
||||
// maps to exactly one source texel centre and filtering would only blur it.
|
||||
let sd = D3D11_SAMPLER_DESC {
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
ComparisonFunc: D3D11_COMPARISON_NEVER,
|
||||
MaxLOD: f32::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("rgb10 vs")?,
|
||||
ps: ps.context("rgb10 ps")?,
|
||||
sampler: sampler.context("rgb10 sampler")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain (non-planar) RTV of the packed 10-bit output texture. Built once per out-ring slot,
|
||||
/// like the P010 plane views — never per frame.
|
||||
pub(crate) fn rtv(
|
||||
device: &ID3D11Device,
|
||||
dst: &ID3D11Texture2D,
|
||||
) -> Result<ID3D11RenderTargetView> {
|
||||
// SAFETY: one `?`-checked `CreateRenderTargetView` on the live `device` borrow, with a
|
||||
// fully-initialized descriptor local whose address is taken only for the synchronous call,
|
||||
// plus a live `Option` out-param.
|
||||
unsafe {
|
||||
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
|
||||
Format: DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 },
|
||||
},
|
||||
};
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device
|
||||
.CreateRenderTargetView(
|
||||
dst,
|
||||
Some(&desc as *const D3D11_RENDER_TARGET_VIEW_DESC),
|
||||
Some(&mut rtv),
|
||||
)
|
||||
.context("CreateRenderTargetView(R10G10B10A2 out slot)")?;
|
||||
rtv.context("rgb10 rtv null")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `src_srv` (FP16 scRGB, WxH) into the `R10G10B10A2` texture behind `rtv`.
|
||||
pub(crate) fn convert(
|
||||
&self,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
rtv: &ID3D11RenderTargetView,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<()> {
|
||||
// SAFETY: all D3D11 work runs on the caller's live `ctx` borrow (the owning capture
|
||||
// thread's immediate context) over borrowed slices of fully-initialized locals and clones
|
||||
// of the caller's live SRV/RTV. No raw pointers and no mapping on this path.
|
||||
unsafe {
|
||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
||||
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: w as f32,
|
||||
Height: h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
ctx.RSSetViewports(Some(&[vp]));
|
||||
ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
|
||||
ctx.PSSetShader(&self.ps, None);
|
||||
ctx.Draw(3, 0);
|
||||
// Unbind for the next frame's re-RTV / NVENC read.
|
||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||
ctx.PSSetShaderResources(0, Some(&[None]));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scRGB FP16 → **P010** (BT.2020 PQ, 10-bit limited/studio range) conversion, in OUR OWN shader (two
|
||||
/// passes: full-res luma + half-res chroma). NVIDIA's D3D11 VideoProcessor cannot do RGB→P010 (renders
|
||||
/// green), so we quantize to studio-range 10-bit YUV directly and feed NVENC native P010 — skipping
|
||||
/// NVENC's internal RGB→YUV CSC (which runs on the contended SM). One per capture device (rebuilt on
|
||||
/// device recreate).
|
||||
/// device recreate). The 4:4:4 twin is [`HdrRgb10Converter`].
|
||||
///
|
||||
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
||||
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
|
||||
WinCaptureTarget,
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
|
||||
VideoConverter, WinCaptureTarget,
|
||||
};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -44,8 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM,
|
||||
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
|
||||
DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
||||
@@ -178,6 +178,9 @@ struct OutSlot {
|
||||
/// `(luma R16_UNORM, chroma R16G16_UNORM)` plane views. `None` for NV12/BGRA outputs, which the
|
||||
/// video processor or a plain `CopyResource` writes without an RTV of ours.
|
||||
p010: Option<(ID3D11RenderTargetView, ID3D11RenderTargetView)>,
|
||||
/// Plain RTV of the packed 10-bit slot, for the HDR + 4:4:4 output ([`HdrRgb10Converter`]).
|
||||
/// `None` for every other format.
|
||||
rgb10: Option<ID3D11RenderTargetView>,
|
||||
}
|
||||
|
||||
/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder
|
||||
@@ -438,8 +441,9 @@ pub struct IddPushCapturer {
|
||||
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
|
||||
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
|
||||
/// TRUE 4:4:4 and the conversion follows the VUI matrix (BT.709 limited, always written).
|
||||
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
|
||||
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||
/// While the display is HDR the same idea runs at 10 bits: [`HdrRgb10Converter`] writes packed
|
||||
/// BT.2020 PQ RGB and NVENC CSCs that to YUV 4:4:4 (Main 4:4:4 10). Either way the chroma the
|
||||
/// Welcome promised is the chroma the wire carries.
|
||||
want_444: bool,
|
||||
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
|
||||
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
|
||||
@@ -521,10 +525,15 @@ pub struct IddPushCapturer {
|
||||
/// SDR — keeps the colour-convert OFF the contended 3D/compute engine. Built lazily; rebuilt on a
|
||||
/// size/HDR flip.
|
||||
video_conv: Option<VideoConverter>,
|
||||
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is HDR
|
||||
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is
|
||||
/// HDR and the session did NOT negotiate 4:4:4 (that case takes [`Self::hdr_rgb10_conv`])
|
||||
/// (NVIDIA's VideoProcessor can't do RGB→P010). The passes run on the 3D engine, but it still skips
|
||||
/// NVENC's internal SM-side CSC. Built lazily.
|
||||
hdr_p010_conv: Option<HdrP010Converter>,
|
||||
/// FP16 scRGB slot → packed 10-bit BT.2020 PQ RGB, used while the display is HDR **and** the
|
||||
/// session negotiated 4:4:4 — the full-chroma twin of [`Self::hdr_p010_conv`]. Rebuilt with the
|
||||
/// ring on a mode/HDR flip.
|
||||
hdr_rgb10_conv: Option<HdrRgb10Converter>,
|
||||
last_seq: u64,
|
||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||
status_logged: bool,
|
||||
@@ -649,8 +658,10 @@ impl IddPushCapturer {
|
||||
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
|
||||
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
|
||||
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
|
||||
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
|
||||
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||
/// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that
|
||||
/// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to
|
||||
/// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to
|
||||
/// 4:2:0 *after* the Welcome had already promised 4:4:4.)
|
||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
|
||||
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
|
||||
@@ -664,7 +675,10 @@ impl IddPushCapturer {
|
||||
}
|
||||
if self.display_hdr {
|
||||
if self.want_444 {
|
||||
warn_444_hdr_downgrade_once();
|
||||
// HDR + full chroma: packed 10-bit RGB (BT.2020 PQ), which NVENC CSCs to YUV
|
||||
// 4:4:4 itself — the HDR twin of the SDR BGRA passthrough below. No subsampling
|
||||
// anywhere on this path (see `HdrRgb10Converter`).
|
||||
return (DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2);
|
||||
}
|
||||
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||
} else if self.want_444 {
|
||||
@@ -798,6 +812,7 @@ impl IddPushCapturer {
|
||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||
self.hdr_p010_conv = None;
|
||||
self.hdr_rgb10_conv = None;
|
||||
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
|
||||
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
|
||||
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
|
||||
@@ -981,7 +996,12 @@ impl IddPushCapturer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.out_ring.push(OutSlot { tex, p010 });
|
||||
let rgb10 = if format == DXGI_FORMAT_R10G10B10A2_UNORM {
|
||||
Some(HdrRgb10Converter::rtv(&self.device, &tex)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.out_ring.push(OutSlot { tex, p010, rgb10 });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1076,7 +1096,13 @@ impl IddPushCapturer {
|
||||
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
||||
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
|
||||
fn ensure_converter(&mut self) -> Result<()> {
|
||||
if self.display_hdr {
|
||||
if self.display_hdr && self.want_444 {
|
||||
// HDR + full chroma: one full-res pass to packed 10-bit BT.2020 PQ RGB; NVENC does
|
||||
// the RGB→YUV444 CSC (there is nothing to subsample, so no second pass).
|
||||
if self.hdr_rgb10_conv.is_none() {
|
||||
self.hdr_rgb10_conv = Some(HdrRgb10Converter::new(&self.device)?);
|
||||
}
|
||||
} else if self.display_hdr {
|
||||
if self.hdr_p010_conv.is_none() {
|
||||
self.hdr_p010_conv = Some(HdrP010Converter::new(
|
||||
&self.device,
|
||||
@@ -1537,7 +1563,7 @@ impl IddPushCapturer {
|
||||
self.ensure_out_ring()?;
|
||||
self.ensure_converter()?;
|
||||
let s = &self.out_ring[i];
|
||||
(Some((s.tex.clone(), s.p010.clone())), None)
|
||||
(Some((s.tex.clone(), s.p010.clone(), s.rgb10.clone())), None)
|
||||
};
|
||||
let (_, pf) = self.out_format();
|
||||
let ring_len = if self.pyrowave {
|
||||
@@ -1584,11 +1610,20 @@ impl IddPushCapturer {
|
||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
||||
conv.convert(&self.context, src, y_rtv, cbcr_rtv, self.width, self.height)?;
|
||||
}
|
||||
} else if self.display_hdr && self.want_444 {
|
||||
// HDR 4:4:4: FP16 slot SRV → packed 10-bit BT.2020 PQ RGB; NVENC ingests it as
|
||||
// ABGR10 and CSCs to YUV 4:4:4 under FREXT (HEVC Main 4:4:4 10).
|
||||
if let Some(conv) = self.hdr_rgb10_conv.as_ref() {
|
||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
||||
let (_, _, rtv) = out.as_ref().expect("out ring");
|
||||
let rtv = rtv.as_ref().expect("Rgb10a2 out slot has an RTV");
|
||||
conv.convert(&self.context, src, rtv, self.width, self.height)?;
|
||||
}
|
||||
} else if self.display_hdr {
|
||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
||||
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
|
||||
let (_, rtvs) = out.as_ref().expect("out ring");
|
||||
let (_, rtvs, _) = out.as_ref().expect("out ring");
|
||||
// The slot's P010 plane views, built once in `ensure_out_ring`.
|
||||
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
|
||||
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
|
||||
@@ -2008,21 +2043,6 @@ impl Capturer for IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
||||
/// desktop needs the PQ tone curve, which the P010 shader provides at 4:2:0), so the stream
|
||||
/// honestly downgrades — the encoder's `chroma_444` caps cross-check reports it and the in-band
|
||||
/// SPS keeps the client decoding correctly. Once per process: the state can flap mid-session.
|
||||
fn warn_444_hdr_downgrade_once() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"4:4:4 negotiated but the display is HDR — no 10-bit full-chroma source exists; \
|
||||
encoding HDR 4:2:0 (P010) instead (disable HDR on the virtual display for 4:4:4)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IddPushCapturer {
|
||||
fn drop(&mut self) {
|
||||
// A channel session ending while the secure-desktop guard is engaged must not leave the
|
||||
|
||||
@@ -644,6 +644,7 @@ impl IddPushCapturer {
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
hdr_rgb10_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
|
||||
@@ -68,6 +68,8 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
"d3dcommon",
|
||||
"dxgi",
|
||||
"handleapi",
|
||||
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
|
||||
"windef",
|
||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
|
||||
"minwinbase",
|
||||
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
|
||||
|
||||
@@ -32,6 +32,91 @@ const CHANNELS: usize = 2;
|
||||
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||
const MIC_FRAME: usize = 960;
|
||||
|
||||
/// A selectable WASAPI endpoint for the settings pickers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioDevice {
|
||||
/// The `IMMDevice` endpoint id (`{0.0.0.00000000}.{…}`) — the stable key the render and
|
||||
/// capture threads resolve via [`DeviceEnumerator::get_device`]. (The PipeWire twin
|
||||
/// stores `node.name` here; both are "the stable key", so the Settings fields and env
|
||||
/// contract stay OS-agnostic.)
|
||||
pub name: String,
|
||||
/// The endpoint's friendly name ("Speakers (Realtek …)") — what the picker shows.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Enumerate active audio endpoints: `(sinks, sources)` — the WASAPI twin of the PipeWire
|
||||
/// probe (same tuple shape; no devices → the caller simply shows no pickers). Runs on its
|
||||
/// own short-lived MTA thread: the caller is typically a UI thread whose COM apartment is
|
||||
/// STA, where a direct `CoInitializeEx(MTA)` would fail with `RPC_E_CHANGED_MODE`.
|
||||
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-audio-enum".into())
|
||||
.spawn(|| -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")?;
|
||||
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
let mut out = (Vec::new(), Vec::new());
|
||||
for (direction, list) in [
|
||||
(Direction::Render, &mut out.0),
|
||||
(Direction::Capture, &mut out.1),
|
||||
] {
|
||||
let coll = enumerator
|
||||
.get_device_collection(&direction)
|
||||
.context("device collection")?;
|
||||
for i in 0..coll.get_nbr_devices().context("device count")? {
|
||||
// One broken endpoint (driver limbo) must not hide the rest.
|
||||
let Ok(dev) = coll.get_device_at_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let (Ok(id), Ok(name)) = (dev.get_id(), dev.get_friendlyname()) else {
|
||||
continue;
|
||||
};
|
||||
list.push(AudioDevice {
|
||||
name: id,
|
||||
description: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.context("spawn audio enumeration thread")?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("audio enumeration thread panicked"))?
|
||||
}
|
||||
|
||||
/// The endpoint an env pick names (`PUNKTFUNK_AUDIO_SINK`/`SOURCE` — endpoint ids, the
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
endpoint = %d.get_friendlyname().unwrap_or_else(|_| id.clone()),
|
||||
"using the picked audio endpoint"
|
||||
);
|
||||
return Ok(d);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
var,
|
||||
endpoint_id = %id,
|
||||
error = %e,
|
||||
"picked audio endpoint not found — using the default"
|
||||
),
|
||||
}
|
||||
}
|
||||
enumerator
|
||||
.get_default_device(direction)
|
||||
.context("default endpoint")
|
||||
}
|
||||
|
||||
pub struct AudioPlayer {
|
||||
pcm_tx: SyncSender<Vec<f32>>,
|
||||
/// Drained chunk Vecs coming back from the render thread for reuse (the pool half of
|
||||
@@ -66,7 +151,8 @@ impl AudioPlayer {
|
||||
.context("spawn audio thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
|
||||
// Default endpoint unless PUNKTFUNK_AUDIO_SINK picked one (logged there).
|
||||
tracing::info!(channels, "WASAPI render: 48 kHz f32");
|
||||
Ok(AudioPlayer {
|
||||
pcm_tx,
|
||||
recycle_rx,
|
||||
@@ -124,10 +210,9 @@ fn render_thread(
|
||||
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
|
||||
// to the old fixed path (mask 0x3, block align 8).
|
||||
let block_align = channels as usize * 4;
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint")?;
|
||||
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
let device = pick_device(&enumerator, &Direction::Render, "PUNKTFUNK_AUDIO_SINK")
|
||||
.context("render endpoint")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
|
||||
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
|
||||
@@ -265,10 +350,9 @@ fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()
|
||||
.map_err(|e| anyhow!("opus encoder: {e}"))?;
|
||||
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
|
||||
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Capture)
|
||||
.context("default capture endpoint (no microphone?)")?;
|
||||
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
let device = pick_device(&enumerator, &Direction::Capture, "PUNKTFUNK_AUDIO_SOURCE")
|
||||
.context("capture endpoint (no microphone?)")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
|
||||
let (default_period, _min_period) =
|
||||
|
||||
@@ -366,11 +366,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
|
||||
.then(|| parse_addr_port(&link.host_ref))
|
||||
.flatten();
|
||||
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == candidate.0 && h.port == candidate.1)
|
||||
{
|
||||
if let Some(i) = known.index_by_addr(&candidate.0, candidate.1) {
|
||||
return HostResolution::Known(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,7 @@ impl ConnectPlan {
|
||||
// exactly the right thing: no profile binding, no clipboard opt-in.
|
||||
let fallback = KnownHost::default();
|
||||
let stored = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == host.addr && h.port == host.port)
|
||||
.find_by_addr(&host.addr, host.port)
|
||||
.unwrap_or(&fallback);
|
||||
let mut plan = ConnectPlan::resolve(
|
||||
stored,
|
||||
@@ -230,6 +228,12 @@ impl ConnectPlan {
|
||||
if self.settings.fullscreen_on_stream {
|
||||
args.push("--fullscreen".into());
|
||||
}
|
||||
// Deliberately NO `--window-pos` here. The Windows shell appends its own (its
|
||||
// window's desktop coordinates place the session on the same monitor), but on
|
||||
// Wayland neither GTK can read global window coordinates nor can SDL apply
|
||||
// them — the compositor owns placement — so from the GTK/CLI spawners the flag
|
||||
// would be a silent no-op everywhere it matters. X11 could carry it, but a
|
||||
// Linux-only special case that most Linux sessions ignore isn't worth the drift.
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,30 @@ pub struct SessionParams {
|
||||
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
|
||||
/// re-reading any store (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
|
||||
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
|
||||
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
|
||||
/// (design/phase-locked-capture.md — previously Apple/Android only). Never set without
|
||||
/// present timing: the host arms on report receipt, but the Hello should say what the
|
||||
/// client actually does.
|
||||
pub phase_lock: bool,
|
||||
/// The presenter-written latch grid the pump's reports are computed from.
|
||||
pub latch_grid: Arc<LatchGrid>,
|
||||
}
|
||||
|
||||
/// The presenter's display-latch grid, shared presenter → pump (the `force_software`
|
||||
/// pattern in the other direction): the presenter's 1 Hz present-timing fold writes a
|
||||
/// recent on-glass latch instant plus the panel period; the pump's stats window folds its
|
||||
/// per-AU arrival stamps against them into the ~1 Hz `PhaseReport`. All zeros until the
|
||||
/// first fold — and forever when present timing isn't available — so the pump simply
|
||||
/// stays quiet then.
|
||||
#[derive(Default)]
|
||||
pub struct LatchGrid {
|
||||
/// A recent on-glass latch instant (client `CLOCK_REALTIME` ns — the same domain as
|
||||
/// the AU arrival stamps). Any grid point works; the report extrapolates forward.
|
||||
pub anchor_ns: std::sync::atomic::AtomicU64,
|
||||
/// The panel's latch period (ns). `0` = no grid yet.
|
||||
pub period_ns: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
||||
@@ -124,6 +148,21 @@ pub struct Stats {
|
||||
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
|
||||
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
|
||||
pub decoder: &'static str,
|
||||
/// The encoder's CURRENT target bitrate (kbps): the Welcome resolve, then live per
|
||||
/// `BitrateChanged` ack. What `mbps` (measured goodput) is judged AGAINST — a user
|
||||
/// staring at "19 Mb/s" can't otherwise tell "the encoder is capped at 20" from "my
|
||||
/// 200 Mb/s ask was honoured and this scene is cheap" (the gap that let the
|
||||
/// settings-drop bug ship four releases). `0` = an old host that never reported one.
|
||||
pub target_kbps: u32,
|
||||
/// Automatic bitrate is armed (ABR moves `target_kbps` on its own) — the OSD tags the
|
||||
/// target `(auto)` so a moving figure reads as policy, not a broken setting.
|
||||
pub auto_rate: bool,
|
||||
/// The host resolved full-chroma 4:4:4 for this session (`Welcome::chroma_format`).
|
||||
pub chroma_444: bool,
|
||||
/// This session ADVERTISED `VIDEO_CAP_444` (the Settings "Full chroma" opt-in): with
|
||||
/// `chroma_444` false, the host declined — the OSD says so instead of leaving the
|
||||
/// switch's effect unobservable.
|
||||
pub asked_444: bool,
|
||||
}
|
||||
|
||||
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
||||
@@ -267,11 +306,17 @@ fn pump(
|
||||
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
|
||||
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
|
||||
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
|
||||
if params.cursor_forward {
|
||||
// CURSOR: this embedder renders the host cursor locally in desktop mouse mode.
|
||||
// PHASE_LOCK: the presenter has real latch stamps and the pump reports them below.
|
||||
(if params.cursor_forward {
|
||||
punktfunk_core::quic::CLIENT_CAP_CURSOR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
// Slice-progressive delivery: off — this presenter feeds FFmpeg whole AUs; a partial
|
||||
// avcodec feed path can flip it later.
|
||||
false,
|
||||
@@ -361,6 +406,12 @@ fn pump(
|
||||
}
|
||||
};
|
||||
let force_software = params.force_software.clone();
|
||||
// Session-constant stats facts (design/stats-unification.md): what the target figure is
|
||||
// judged against and whether the 4:4:4 opt-in was honoured. `target_kbps` itself is read
|
||||
// live per window — an Automatic session's ABR moves it.
|
||||
let auto_rate = connector.wants_decode_latency();
|
||||
let chroma_444 = connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444;
|
||||
let asked_444 = params.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
@@ -391,6 +442,13 @@ fn pump(
|
||||
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
// Phase-lock (advertised above): every received AU's arrival stamp, folded per stats
|
||||
// window against the presenter's latch grid into the ~1 Hz PhaseReport. Desktop
|
||||
// sessions receive whole AUs only (no frame parts), so every arrival counts — the
|
||||
// reference reporters (Apple/Android) sample the same signal. 256 ≈ 2 s at 120 Hz.
|
||||
let latch_grid = params.latch_grid.clone();
|
||||
let mut phase_arrivals: Vec<u64> = Vec::new();
|
||||
let mut last_applied_phase: Option<i32> = None;
|
||||
// PUNKTFUNK_DEBUG_RECONFIGURE=WxH@HZ:SECS — lab lever: request ONE mid-stream mode
|
||||
// switch N seconds in, so a headless session (no window manager to drag a window in)
|
||||
// can exercise the resize path deterministically — host pipeline rebuild, decoder
|
||||
@@ -480,6 +538,9 @@ fn pump(
|
||||
} else {
|
||||
now_ns()
|
||||
};
|
||||
if params.phase_lock && phase_arrivals.len() < 256 {
|
||||
phase_arrivals.push(received_ns);
|
||||
}
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
@@ -733,6 +794,19 @@ fn pump(
|
||||
// host never emits any — the deque fills to its cap and the OSD keeps the
|
||||
// combined `host+network` stage.
|
||||
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
|
||||
// Phase-lock closed loop: the host's applied grid offset rides the 0xCF tail.
|
||||
// Log transitions so an on-glass run can watch the controller engage/settle
|
||||
// (the Android reporter's parity log).
|
||||
if params.phase_lock
|
||||
&& t.applied_phase_ns.is_some()
|
||||
&& t.applied_phase_ns != last_applied_phase
|
||||
{
|
||||
last_applied_phase = t.applied_phase_ns;
|
||||
tracing::info!(
|
||||
applied_phase_ns = t.applied_phase_ns.unwrap_or(0),
|
||||
"host phase-lock: applied capture-grid offset"
|
||||
);
|
||||
}
|
||||
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
|
||||
let (_, hn_us) = pending_split.remove(i).unwrap();
|
||||
host_us_win.push(t.host_us as u64);
|
||||
@@ -775,6 +849,41 @@ fn pump(
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
// Phase-lock report (~1 Hz, riding the stats window — the reference reporters'
|
||||
// cadence): this window's arrival leads before the presenter's latch grid,
|
||||
// folded with the SHARED circular statistic (the host controller was tuned
|
||||
// against it). Quiet until the presenter has a grid (period 0 — no
|
||||
// present-timing samples yet) or the window is thin (< 8 arrivals —
|
||||
// `circular_latch` declines). 1 ms uncertainty = Apple/Android parity.
|
||||
if params.phase_lock {
|
||||
let period = latch_grid.period_ns.load(Ordering::Relaxed);
|
||||
let anchor = latch_grid.anchor_ns.load(Ordering::Relaxed);
|
||||
if period > 0 && anchor > 0 {
|
||||
let leads_us: Vec<u64> = phase_arrivals
|
||||
.iter()
|
||||
.map(|a| {
|
||||
((anchor as i128 - *a as i128).rem_euclid(period as i128) / 1000) as u64
|
||||
})
|
||||
.collect();
|
||||
if let Some((lead_ns, coherence)) =
|
||||
punktfunk_core::phase::circular_latch(&leads_us, period as i64)
|
||||
{
|
||||
// Extrapolate the (possibly ~1 s old) anchor to the next latch at
|
||||
// or after now, then express it on the host clock.
|
||||
let (now, p, a) = (now_ns() as i128, period as i128, anchor as i128);
|
||||
let k = ((now - a).max(0) + p - 1) / p;
|
||||
let offset = clock_offset_live.load(Ordering::Relaxed) as i128;
|
||||
connector.report_phase(
|
||||
(a + k * p + offset).max(0) as u64,
|
||||
period.min(u32::MAX as u64) as u32,
|
||||
1_000_000,
|
||||
lead_ns.min(u32::MAX as u64) as u32,
|
||||
coherence,
|
||||
);
|
||||
}
|
||||
}
|
||||
phase_arrivals.clear();
|
||||
}
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
let (hn_p50, _) = window_percentiles(&mut hostnet_us);
|
||||
let (dec_p50, _) = window_percentiles(&mut decode_us);
|
||||
@@ -823,6 +932,10 @@ fn pump(
|
||||
0.0
|
||||
},
|
||||
decoder: dec_path,
|
||||
target_kbps: connector.current_bitrate_kbps(),
|
||||
auto_rate,
|
||||
chroma_444,
|
||||
asked_444,
|
||||
}));
|
||||
window_start = Instant::now();
|
||||
frames_n = 0;
|
||||
|
||||
@@ -268,8 +268,40 @@ impl KnownHosts {
|
||||
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
||||
}
|
||||
|
||||
/// The record an address-keyed lookup resolves to, by index (so callers that go on to
|
||||
/// mutate the store don't fight the borrow checker).
|
||||
///
|
||||
/// One address cannot host two live identities at once, but the store can still hold more
|
||||
/// than one record claiming `addr:port`: an fp-less placeholder waiting for its first
|
||||
/// ceremony, or — before [`KnownHosts::upsert_trusted`] existed — a re-keyed host whose new
|
||||
/// record was appended beside the dead one. Resolving that positionally is what turned a
|
||||
/// host reinstall into a permanent lockout: the dead pin, written first, won every later
|
||||
/// connect, including right after a successful re-pair.
|
||||
///
|
||||
/// So the rule is "the newest trust decision wins": a real fingerprint beats a placeholder,
|
||||
/// and among real ones the LAST record — records are only ever appended by an explicit
|
||||
/// trust decision, so the last one is the most recent thing the user actually authorised.
|
||||
/// That is a lookup order, never an authorisation: whichever record this picks, the pin it
|
||||
/// yields still has to match the certificate the host presents, or the connect fails closed.
|
||||
pub fn index_by_addr(&self, addr: &str, port: u16) -> Option<usize> {
|
||||
let mut best: Option<usize> = None;
|
||||
for (i, h) in self.hosts.iter().enumerate() {
|
||||
if h.addr != addr || h.port != port {
|
||||
continue;
|
||||
}
|
||||
let better = match best {
|
||||
None => true,
|
||||
Some(b) => !h.fp_hex.is_empty() || self.hosts[b].fp_hex.is_empty(),
|
||||
};
|
||||
if better {
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
||||
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
||||
self.index_by_addr(addr, port).map(|i| &self.hosts[i])
|
||||
}
|
||||
|
||||
/// Forget the entry with this fingerprint. Returns true if one was removed (the user
|
||||
@@ -322,6 +354,70 @@ impl KnownHosts {
|
||||
self.hosts.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`upsert`](Self::upsert) for an **authorised trust decision** — a PIN ceremony, a
|
||||
/// delegated approval, a TOFU accept, a headless pair — which additionally retires every
|
||||
/// other record claiming the same `addr:port`.
|
||||
///
|
||||
/// `upsert` alone keys on the fingerprint, deliberately: that is how a host which moved
|
||||
/// address keeps its record and the fields the user set on it. The cost was that a host
|
||||
/// which changed IDENTITY — a reinstall, a wiped `ProgramData`, a re-key — matched nothing
|
||||
/// and got a SECOND record appended for the address it already had, and every later
|
||||
/// connect then pinned the dead fingerprint from the older one. No way out from the UI,
|
||||
/// and re-pairing didn't help: the ceremony succeeded and appended yet another record.
|
||||
///
|
||||
/// A record retired here carries what describes the BOX rather than the identity onto the
|
||||
/// record that survives — its MAC, its OS chain, the profile bound to it, its pinned cards,
|
||||
/// when it was last used — so a reinstall doesn't quietly cost the user their setup.
|
||||
/// Deliberately NOT carried: `paired` and `clipboard_sync`, which are decisions about one
|
||||
/// specific certificate and have to be made again for a new one, and the stable record id
|
||||
/// (a deep link written from the retired record falls through to the `host=` recovery the
|
||||
/// link grammar already specifies, rather than silently pointing at a new identity).
|
||||
///
|
||||
/// **Only trust decisions may call this.** Everything that merely LEARNS something about a
|
||||
/// host — a rediscovery, the wake path's address re-key — stays on plain `upsert`: those
|
||||
/// are driven by unauthenticated mDNS, and letting an advert delete a saved host by
|
||||
/// claiming its address would trade this bug for a much worse one.
|
||||
pub fn upsert_trusted(&mut self, entry: KnownHost) {
|
||||
let (addr, port, fp_hex) = (entry.addr.clone(), entry.port, entry.fp_hex.clone());
|
||||
self.upsert(entry);
|
||||
// Nothing to supersede *with*: an fp-less record is a placeholder, not an identity.
|
||||
if fp_hex.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (keep, retired): (Vec<KnownHost>, Vec<KnownHost>) = std::mem::take(&mut self.hosts)
|
||||
.into_iter()
|
||||
.partition(|h| !(h.addr == addr && h.port == port && h.fp_hex != fp_hex));
|
||||
self.hosts = keep;
|
||||
if retired.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
||||
return;
|
||||
};
|
||||
for old in retired {
|
||||
tracing::info!(
|
||||
addr = %addr, port,
|
||||
retired_fp = %old.fp_hex, kept_fp = %fp_hex,
|
||||
"host re-keyed — retiring the superseded record for this address"
|
||||
);
|
||||
if h.mac.is_empty() {
|
||||
h.mac = old.mac;
|
||||
}
|
||||
if h.os.is_empty() {
|
||||
h.os = old.os;
|
||||
}
|
||||
if h.profile_id.is_none() {
|
||||
h.profile_id = old.profile_id;
|
||||
}
|
||||
if h.pinned_profiles.is_empty() {
|
||||
h.pinned_profiles = old.pinned_profiles;
|
||||
}
|
||||
if h.last_used.is_none() {
|
||||
h.last_used = old.last_used;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load-upsert-save in one step — the pin every trust decision (TOFU accept, PIN
|
||||
@@ -332,7 +428,10 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
// so every user-set field (clipboard, profile binding, pins) must arrive as "not carried"
|
||||
// — `upsert` then leaves an existing host's own settings alone. A hand-written literal
|
||||
// here is how those fields would get silently reset on the next re-pair.
|
||||
known.upsert(KnownHost {
|
||||
//
|
||||
// `upsert_trusted`, not `upsert`: this IS the authorised decision, so it is also the point
|
||||
// at which a host that re-keyed retires its own dead record for this address.
|
||||
known.upsert_trusted(KnownHost {
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
@@ -366,6 +465,23 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
) -> Option<&'a mut KnownHost> {
|
||||
let i = (!fp_hex.is_empty())
|
||||
.then(|| known.hosts.iter().position(|h| h.fp_hex == fp_hex))
|
||||
.flatten()
|
||||
.or_else(|| known.index_by_addr(addr, port))?;
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
@@ -374,11 +490,7 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||
else {
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
@@ -396,11 +508,7 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||
else {
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
@@ -785,9 +893,10 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub invert_scroll: bool,
|
||||
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
|
||||
/// playback stream targets (`target.object`); empty = the session default (the
|
||||
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
|
||||
/// Ignored on Windows until the WASAPI endpoint leg exists.
|
||||
/// playback stream targets (`target.object`); on Windows the WASAPI `IMMDevice`
|
||||
/// endpoint id; empty = the OS default (the Apple client's Speaker picker). The
|
||||
/// session maps it onto `PUNKTFUNK_AUDIO_SINK`. A picked endpoint that's gone
|
||||
/// falls back to the default on both OSes.
|
||||
#[serde(default)]
|
||||
pub speaker_device: String,
|
||||
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
|
||||
@@ -960,10 +1069,9 @@ pub fn effective_settings(
|
||||
) -> (Settings, Option<StreamProfile>) {
|
||||
let base = Settings::load();
|
||||
let catalog = ProfilesFile::load();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
let known = KnownHosts::load();
|
||||
let bound = known
|
||||
.find_by_addr(addr, port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
|
||||
match resolve_profile(&catalog, bound.as_deref(), one_off) {
|
||||
@@ -1003,6 +1111,12 @@ fn resolve_profile(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 64-hex fingerprint of one repeated digit — readable in an assertion, and distinct
|
||||
/// per letter, which is all the known-hosts tests need one to be.
|
||||
fn fp(c: char) -> String {
|
||||
std::iter::repeat_n(c, 64).collect()
|
||||
}
|
||||
|
||||
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
|
||||
/// default), and the name round-trips through the enum both ways.
|
||||
#[test]
|
||||
@@ -1220,6 +1334,243 @@ mod tests {
|
||||
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
|
||||
}
|
||||
|
||||
/// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with
|
||||
/// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on
|
||||
/// the fingerprint, so the re-paired host used to be appended beside the dead record, and
|
||||
/// every later connect pinned the dead one — forever, re-pairing included.
|
||||
#[test]
|
||||
fn upsert_trusted_supersedes_a_rekeyed_host() {
|
||||
let (dead, live) = (fp('c'), fp('a'));
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![KnownHost {
|
||||
name: "ENRICOS-DESKTOP (local)".into(),
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: dead.clone(),
|
||||
paired: true,
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
os: "windows".into(),
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
id: Some("11111111-2222-4333-8444-555555555555".into()),
|
||||
}],
|
||||
};
|
||||
// The re-pair: same box, same address, a certificate the client has never seen.
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: "127.0.0.1".into(),
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: live.clone(),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts.len(), 1);
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(h.fp_hex, live);
|
||||
// …and the address now resolves to the live pin, which is the whole bug.
|
||||
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
|
||||
assert!(k.find_by_fp(&dead).is_none());
|
||||
// What describes the BOX rides along, so a reinstall doesn't cost the user their setup.
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert_eq!(h.os, "windows");
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
assert_eq!(h.last_used, Some(1000));
|
||||
// What described the dead IDENTITY does not: the clipboard grant is a decision about
|
||||
// one certificate, and the retired record's stable id must not follow a new one.
|
||||
assert!(!h.clipboard_sync);
|
||||
assert_ne!(
|
||||
h.id.as_deref(),
|
||||
Some("11111111-2222-4333-8444-555555555555")
|
||||
);
|
||||
}
|
||||
|
||||
/// The case fingerprint-keying exists for still works through the trusted path: a host that
|
||||
/// only MOVED keeps its one record, its `paired` bit and everything the user set on it —
|
||||
/// including the clipboard grant and the stable id, which a same-identity re-pair must not
|
||||
/// disturb (that would be the fix trading one silent reset for another).
|
||||
#[test]
|
||||
fn upsert_trusted_keeps_a_host_that_only_moved_address() {
|
||||
let same = fp('a');
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: same.clone(),
|
||||
paired: true,
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
id: Some("11111111-2222-4333-8444-555555555555".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.51".into(),
|
||||
port: 9777,
|
||||
fp_hex: same.clone(),
|
||||
paired: false, // must not demote
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts.len(), 1);
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(h.addr, "192.168.1.51");
|
||||
assert!(h.paired);
|
||||
assert!(h.clipboard_sync);
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(
|
||||
h.id.as_deref(),
|
||||
Some("11111111-2222-4333-8444-555555555555")
|
||||
);
|
||||
}
|
||||
|
||||
/// Superseding is scoped to the address the decision was made for, and only ever runs off
|
||||
/// one: a trust decision for `.51` leaves a different host saved at `.50` alone, and an
|
||||
/// fp-less save (a manual entry, `--add-host` without `--fp`) retires nothing at all — it
|
||||
/// carries no identity to supersede anything WITH.
|
||||
#[test]
|
||||
fn upsert_trusted_leaves_other_addresses_and_placeholders_alone() {
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![
|
||||
KnownHost {
|
||||
name: "Other box".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: fp('c'),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
},
|
||||
// Same address, DIFFERENT port: a distinct endpoint, not a duplicate.
|
||||
KnownHost {
|
||||
name: "Second host".into(),
|
||||
addr: "192.168.1.51".into(),
|
||||
port: 9778,
|
||||
fp_hex: fp('d'),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: "New box".into(),
|
||||
addr: "192.168.1.51".into(),
|
||||
port: 9777,
|
||||
fp_hex: fp('a'),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts.len(), 3);
|
||||
assert_eq!(
|
||||
k.find_by_addr("192.168.1.50", 9777).unwrap().fp_hex,
|
||||
fp('c')
|
||||
);
|
||||
assert_eq!(
|
||||
k.find_by_addr("192.168.1.51", 9778).unwrap().fp_hex,
|
||||
fp('d')
|
||||
);
|
||||
|
||||
// An fp-less save alongside a real record: nothing is retired, and the address still
|
||||
// resolves to the record that HAS a pin.
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: "Typed by hand".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts.len(), 4);
|
||||
assert_eq!(
|
||||
k.find_by_addr("192.168.1.50", 9777).unwrap().fp_hex,
|
||||
fp('c')
|
||||
);
|
||||
}
|
||||
|
||||
/// A store that ALREADY holds the duplicate (every client shipped so far can have written
|
||||
/// one) connects again on the next connect, before any re-pair: an address resolves to the
|
||||
/// newest trust decision for it, not to whichever record happens to sit first in the file.
|
||||
/// Nothing is deleted at load — which record is live isn't knowable there, and guessing
|
||||
/// wrong would throw away the good one; the retirement waits for the next trust decision.
|
||||
#[test]
|
||||
fn a_duplicated_store_resolves_to_the_newest_record() {
|
||||
let (dead, live) = (fp('c'), fp('a'));
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![
|
||||
KnownHost {
|
||||
name: "ENRICOS-DESKTOP (local)".into(),
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: dead.clone(),
|
||||
paired: true,
|
||||
last_used: Some(9999), // the stale record is the one that HAS connected
|
||||
..Default::default()
|
||||
},
|
||||
KnownHost {
|
||||
name: "127.0.0.1".into(),
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: live.clone(),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
|
||||
// Loading is non-destructive: both records are still there to be looked up by pin.
|
||||
assert!(k.find_by_fp(&dead).is_some());
|
||||
// A placeholder appended later never displaces a real pin.
|
||||
k.hosts.push(KnownHost {
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
|
||||
// …and the next trust decision cleans the store up.
|
||||
k.upsert_trusted(KnownHost {
|
||||
name: "127.0.0.1".into(),
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: live.clone(),
|
||||
paired: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts.len(), 1);
|
||||
assert_eq!(k.hosts[0].fp_hex, live);
|
||||
}
|
||||
|
||||
/// An advert's learned MAC/OS lands on the record it identified, not on a stale namesake
|
||||
/// at the same address that merely came first in the file.
|
||||
#[test]
|
||||
fn learn_target_prefers_the_fingerprint_match() {
|
||||
let (dead, live) = (fp('c'), fp('a'));
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![
|
||||
KnownHost {
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: dead.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
KnownHost {
|
||||
addr: "127.0.0.1".into(),
|
||||
port: 9777,
|
||||
fp_hex: live.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
learn_target(&mut k, &live, "127.0.0.1", 9777).unwrap().os = "windows".into();
|
||||
assert_eq!(k.find_by_fp(&live).unwrap().os, "windows");
|
||||
assert_eq!(k.find_by_fp(&dead).unwrap().os, "");
|
||||
// No fingerprint to go on (an advert that carries none) → the address's own answer.
|
||||
learn_target(&mut k, "", "127.0.0.1", 9777).unwrap().os = "linux".into();
|
||||
assert_eq!(k.find_by_fp(&live).unwrap().os, "linux");
|
||||
assert_eq!(k.find_by_fp(&dead).unwrap().os, "");
|
||||
// An advert for a host this store has never seen writes nothing.
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -110,6 +110,21 @@ pub struct VkVideoFrame {
|
||||
pub decode_done_value: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The decode POOL's allocated extent (`AVHWFramesContext.width`/`.height`) — the
|
||||
/// CODED picture size (rounded up to the codec's macroblock alignment, then to the
|
||||
/// driver's Vulkan picture-access granularity), so it is `>=` `width`/`height`. At
|
||||
/// 1080p the pool is 1088 rows tall: 1080 is not a multiple of 16.
|
||||
///
|
||||
/// The presenter samples this image with NORMALIZED coordinates, so it needs both
|
||||
/// numbers — `width`/`height` is what to display, `coded_*` is what the texture
|
||||
/// actually spans. Sampling `0..1` without the ratio stretches the alignment padding
|
||||
/// into view; because encoders fill those rows by replicating the picture's last
|
||||
/// line, that reads as the bottom row smeared over the final few rows of the image
|
||||
/// (field report 2026-07-31). Same class as the D3D11VA source-rect clamp in
|
||||
/// `crate::video_d3d11`, which shows as a green bar there only because DXVA padding
|
||||
/// is left uninitialized rather than replicated.
|
||||
pub coded_width: u32,
|
||||
pub coded_height: u32,
|
||||
pub color: ColorDesc,
|
||||
/// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on
|
||||
/// one after suppressing the concealed frames a reference loss leaves in its wake (on
|
||||
@@ -876,6 +891,10 @@ pub struct VulkanDecodeDevice {
|
||||
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
|
||||
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
|
||||
pub video_decode: bool,
|
||||
/// The presenter has REAL on-glass present timing (`VK_KHR_present_wait` — its
|
||||
/// `PresentTimer` runs). Gates the `CLIENT_CAP_PHASE_LOCK` advertisement: without a
|
||||
/// true latch stamp the desktop has no latch grid and must not claim the cap.
|
||||
pub present_timing: bool,
|
||||
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
|
||||
/// features its kernels need were present AND enabled at device creation
|
||||
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
|
||||
@@ -950,6 +969,9 @@ pub(crate) fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32>
|
||||
Some(match sw {
|
||||
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
|
||||
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
|
||||
// Full-chroma 4:4:4 semi-planar (HEVC RExt decode on drivers that export it as
|
||||
// two planes) — the presenter imports the full-size chroma plane like any other.
|
||||
AV_PIX_FMT_NV24 => fourcc(b'N', b'V', b'2', b'4'),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -984,6 +1006,7 @@ mod tests {
|
||||
queue_families: Vec::new(),
|
||||
pyrowave_decode: false,
|
||||
video_decode: true,
|
||||
present_timing: false,
|
||||
d3d11_import: false,
|
||||
d3d11_hdr10: false,
|
||||
adapter_luid: None,
|
||||
@@ -1024,6 +1047,10 @@ mod tests {
|
||||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
Some(0x3231_564e)
|
||||
);
|
||||
assert_eq!(
|
||||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV24),
|
||||
Some(0x3432_564e)
|
||||
);
|
||||
assert_eq!(
|
||||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
|
||||
None
|
||||
|
||||
@@ -898,3 +898,87 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This desktop's HDR colour volume (`IDXGIOutput6::GetDesc1`) → the Hello's
|
||||
/// `display_hdr`, so the host's virtual-display EDID matches THIS panel instead of its
|
||||
/// generic defaults (host apps then tone-map to the real glass). `pos` picks the output
|
||||
/// containing that desktop point — the `--window-pos` monitor, where the stream window
|
||||
/// will open; no `pos` or no match falls back to the output holding the desktop origin
|
||||
/// (the primary). Returns `None` when that output's advanced color is off (an SDR
|
||||
/// colorspace): claiming an HDR volume for a desktop that won't present HDR would steer
|
||||
/// host tone mapping wrong, and the host's EDID defaults are the honest answer there.
|
||||
/// (`PUNKTFUNK_CLIENT_PEAK_NITS` still overrides whatever this reports — see
|
||||
/// `punktfunk_core::client::display_hdr_env_override`.)
|
||||
pub fn display_hdr_volume(pos: Option<(i32, i32)>) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
use windows::Win32::dxgi::{IDXGIOutput6, DXGI_OUTPUT_DESC1};
|
||||
// SAFETY: plain DXGI factory creation — no arguments to get wrong; the returned
|
||||
// interface is owned by this scope and dropped with it.
|
||||
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
|
||||
let mut fallback: Option<DXGI_OUTPUT_DESC1> = None;
|
||||
for a in 0.. {
|
||||
// SAFETY: read-only enumeration on the live factory; the returned adapter is
|
||||
// owned by this scope.
|
||||
let Ok(adapter) = (unsafe { factory.EnumAdapters1(a) }) else {
|
||||
break;
|
||||
};
|
||||
for o in 0.. {
|
||||
// Out-pointer convention in this windows-rs rev (no retval annotation).
|
||||
let mut output: Option<windows::Win32::dxgi::IDXGIOutput> = None;
|
||||
// SAFETY: read-only enumeration on the live adapter, writing a local
|
||||
// out-pointer that outlives the call.
|
||||
if unsafe { adapter.EnumOutputs(o, &mut output) }.ok().is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(output) = output else {
|
||||
break;
|
||||
};
|
||||
let Ok(out6) = output.cast::<IDXGIOutput6>() else {
|
||||
continue; // pre-1809 DXGI — no advanced-color facts to read
|
||||
};
|
||||
let mut desc = DXGI_OUTPUT_DESC1::default();
|
||||
// SAFETY: fills a local, correctly-sized DXGI_OUTPUT_DESC1 that outlives
|
||||
// the call; the interface is live (owned just above).
|
||||
if unsafe { out6.GetDesc1(&mut desc) }.ok().is_err() {
|
||||
continue;
|
||||
}
|
||||
let r = desc.DesktopCoordinates;
|
||||
let contains =
|
||||
|x: i32, y: i32| x >= r.left && x < r.right && y >= r.top && y < r.bottom;
|
||||
if let Some((x, y)) = pos {
|
||||
if contains(x, y) {
|
||||
return hdr_meta_from_output(&desc);
|
||||
}
|
||||
}
|
||||
if fallback.is_none() || contains(0, 0) {
|
||||
fallback = Some(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
hdr_meta_from_output(&fallback?)
|
||||
}
|
||||
|
||||
/// The ST.2086 shape of one output's colour facts; `None` for an SDR colorspace.
|
||||
fn hdr_meta_from_output(
|
||||
d: &windows::Win32::dxgi::DXGI_OUTPUT_DESC1,
|
||||
) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
use windows::Win32::dxgi::DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020;
|
||||
if d.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
return None;
|
||||
}
|
||||
// Chromaticity → 1/50000 units; luminance → 0.0001 cd/m² units (the HdrMeta contract).
|
||||
let c = |v: [f32; 2]| {
|
||||
[
|
||||
(v[0] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
|
||||
(v[1] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
|
||||
]
|
||||
};
|
||||
Some(punktfunk_core::quic::HdrMeta {
|
||||
// ST.2086 primary order is G, B, R (see the HdrMeta docs); DXGI reports R/G/B.
|
||||
display_primaries: [c(d.GreenPrimary), c(d.BluePrimary), c(d.RedPrimary)],
|
||||
white_point: c(d.WhitePoint),
|
||||
max_display_mastering_luminance: (f64::from(d.MaxLuminance) * 10_000.0) as u32,
|
||||
min_display_mastering_luminance: (f64::from(d.MinLuminance) * 10_000.0) as u32,
|
||||
max_cll: d.MaxLuminance.round().clamp(0.0, 65_535.0) as u16,
|
||||
max_fall: d.MaxFullFrameLuminance.round().clamp(0.0, 65_535.0) as u16,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,10 +347,16 @@ impl VulkanDecoder {
|
||||
}
|
||||
let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext;
|
||||
let sw = (*fc).sw_format;
|
||||
// The 2-plane layouts the presenter's CSC can sample: 4:2:0 (NV12/P010) and
|
||||
// full-chroma 4:4:4 (NV24/P410 — HEVC RExt decode, semi-planar like all
|
||||
// NVDEC output). The presenter's `vkframe_plane_formats` table is the final
|
||||
// authority; anything else bails here so the session demotes cleanly.
|
||||
if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_NV24
|
||||
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P410LE
|
||||
{
|
||||
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010 only)");
|
||||
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010/NV24/P410 only)");
|
||||
}
|
||||
let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext;
|
||||
let vk_format = (*vkfc).format[0] as i32;
|
||||
@@ -376,6 +382,13 @@ impl VulkanDecoder {
|
||||
// sem_value was last written by the decode submission on THIS thread.
|
||||
let timeline_sem = (*vkf).sem[0] as u64;
|
||||
let decode_done_value = (*vkf).sem_value[0];
|
||||
log_layout_once(
|
||||
(*self.frame).width,
|
||||
(*self.frame).height,
|
||||
(*fc).width,
|
||||
(*fc).height,
|
||||
sw,
|
||||
);
|
||||
Ok(VkVideoFrame {
|
||||
vkframe: vkf as usize,
|
||||
frames_ctx: fc as usize,
|
||||
@@ -386,6 +399,13 @@ impl VulkanDecoder {
|
||||
decode_done_value,
|
||||
width: (*self.frame).width as u32,
|
||||
height: (*self.frame).height as u32,
|
||||
// The pool extent, not the frame's: `avcodec_get_hw_frames_parameters`
|
||||
// sizes it from `coded_width`/`coded_height` and FFmpeg's Vulkan layer
|
||||
// rounds that up again to the driver's picture-access granularity. The
|
||||
// `max` is defensive — a pool SMALLER than the frame would mean sampling
|
||||
// past the surface, so degrade to "no crop" rather than trust it.
|
||||
coded_width: ((*fc).width.max((*self.frame).width)) as u32,
|
||||
coded_height: ((*fc).height.max((*self.frame).height)) as u32,
|
||||
color: ColorDesc::from_raw(self.frame),
|
||||
keyframe: frame_is_keyframe(self.frame),
|
||||
guard: DrmFrameGuard(clone),
|
||||
@@ -394,6 +414,30 @@ impl VulkanDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time dump of the first decoded frame's layout — the forensics for a new GPU/driver.
|
||||
/// `pool_*` is the allocated decode surface (`>=` the frame); the gap is the alignment
|
||||
/// padding the presenter's UV scale excludes. The D3D11VA path logs the same pair.
|
||||
fn log_layout_once(
|
||||
width: i32,
|
||||
height: i32,
|
||||
pool_w: i32,
|
||||
pool_h: i32,
|
||||
sw: ffmpeg::ffi::AVPixelFormat,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
pool_w,
|
||||
pool_h,
|
||||
?sw,
|
||||
"Vulkan Video first frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VulkanDecoder {
|
||||
fn drop(&mut self) {
|
||||
use ffmpeg::ffi;
|
||||
|
||||
@@ -19,35 +19,54 @@ use skia_safe::{Canvas, Rect};
|
||||
enum RowId {
|
||||
Resolution,
|
||||
Refresh,
|
||||
RenderScale,
|
||||
Bitrate,
|
||||
Compositor,
|
||||
Codec,
|
||||
Decoder,
|
||||
Hdr,
|
||||
Chroma444,
|
||||
Audio,
|
||||
Mic,
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
Mouse,
|
||||
InvertScroll,
|
||||
Shortcuts,
|
||||
Stats,
|
||||
Fullscreen,
|
||||
AutoWake,
|
||||
Library,
|
||||
}
|
||||
|
||||
const ROWS: [RowId; 14] = [
|
||||
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
|
||||
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle all
|
||||
// were). Still deliberately smaller than the desktop dialogs — device pickers
|
||||
// (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
const ROWS: [RowId; 21] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
RowId::Shortcuts,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
];
|
||||
|
||||
const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
@@ -59,6 +78,8 @@ const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
(3840, 2160),
|
||||
];
|
||||
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
|
||||
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
|
||||
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
|
||||
const COMPOSITORS: [(&str, &str); 5] = [
|
||||
("auto", "Automatic"),
|
||||
@@ -76,12 +97,23 @@ const CODECS: [(&str, &str); 5] = [
|
||||
// selected when the host supports it too; anything else falls back to HEVC.
|
||||
("pyrowave", "PyroWave (wired LAN)"),
|
||||
];
|
||||
// Per-OS hardware rungs, like the shells' pickers: the console ships on Windows too
|
||||
// (`punktfunk-session --browse`), where "vaapi" is a dead option that ALSO hid the real
|
||||
// hardware path (d3d11va) — `Decoder::new` has no VAAPI branch there.
|
||||
#[cfg(not(windows))]
|
||||
const DECODERS: [(&str, &str); 4] = [
|
||||
("auto", "Automatic"),
|
||||
("vulkan", "Vulkan Video"),
|
||||
("vaapi", "VAAPI"),
|
||||
("software", "Software"),
|
||||
];
|
||||
#[cfg(windows)]
|
||||
const DECODERS: [(&str, &str); 4] = [
|
||||
("auto", "Automatic"),
|
||||
("vulkan", "Vulkan Video"),
|
||||
("d3d11va", "Direct3D 11"),
|
||||
("software", "Software"),
|
||||
];
|
||||
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
|
||||
const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("auto", "Automatic"),
|
||||
@@ -114,6 +146,15 @@ impl SettingsScreen {
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ROWS.len());
|
||||
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
|
||||
// screen is one of the settings file's several whole-file writers (profiles.rs
|
||||
// documents the no-merge debt), and adjusting a stale snapshot would silently
|
||||
// revert what another writer (a session's match-window persist, a desktop shell)
|
||||
// stored while the console was open. Only on the mutating events — a cursor move
|
||||
// shouldn't touch the disk.
|
||||
if matches!(msg, ListMsg::Adjust(_) | ListMsg::Activate) {
|
||||
*ctx.settings = pf_client_core::trust::Settings::load();
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
|
||||
@@ -200,6 +241,17 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
format!("{} Hz", s.refresh_hz)
|
||||
},
|
||||
),
|
||||
RowId::RenderScale => (
|
||||
None,
|
||||
"Render scale",
|
||||
if s.render_scale == 1.0 {
|
||||
"Native".into()
|
||||
} else if s.render_scale > 1.0 {
|
||||
format!("{}× (supersample)", s.render_scale)
|
||||
} else {
|
||||
format!("{}×", s.render_scale)
|
||||
},
|
||||
),
|
||||
RowId::Bitrate => (
|
||||
None,
|
||||
"Bitrate",
|
||||
@@ -221,6 +273,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
"Audio channels",
|
||||
@@ -254,11 +307,24 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
|
||||
RowId::Shortcuts => (
|
||||
None,
|
||||
"Capture system shortcuts",
|
||||
on_off(s.inhibit_shortcuts).into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
s.stats_verbosity().label().into(),
|
||||
),
|
||||
RowId::Fullscreen => (
|
||||
None,
|
||||
"Start streams fullscreen",
|
||||
on_off(s.fullscreen_on_stream).into(),
|
||||
),
|
||||
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
|
||||
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
|
||||
};
|
||||
RowSpec {
|
||||
header,
|
||||
@@ -278,6 +344,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
Match window follows this window, including mid-stream resizes."
|
||||
}
|
||||
RowId::Refresh => "Native follows the display this window is on.",
|
||||
RowId::RenderScale => {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
|
||||
RowId::Compositor => {
|
||||
"Which compositor drives the virtual output — honored only if available on the host."
|
||||
@@ -287,6 +357,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
RowId::Hdr => {
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it."
|
||||
}
|
||||
RowId::Chroma444 => {
|
||||
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
|
||||
HEVC only, and only where the host can encode it."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
@@ -300,10 +374,21 @@ fn detail(id: RowId) -> &'static str {
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
RowId::Shortcuts => {
|
||||
"Alt+Tab, Super and friends reach the host while input is captured. \
|
||||
Off, they act on this device instead."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
}
|
||||
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
|
||||
RowId::AutoWake => {
|
||||
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
|
||||
reached over a VPN, where the wake wait only adds delay."
|
||||
}
|
||||
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +433,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let cur = REFRESH.iter().position(|r| *r == s.refresh_hz);
|
||||
step_option(cur, REFRESH.len(), delta, wrap).map(|i| s.refresh_hz = REFRESH[i])
|
||||
}
|
||||
RowId::RenderScale => {
|
||||
// Exact float compare is fine: every writer (here and the desktop pickers)
|
||||
// stores one of these literals; a hand-edited oddball snaps to the first step.
|
||||
let cur = RENDER_SCALES.iter().position(|v| *v == s.render_scale);
|
||||
step_option(cur, RENDER_SCALES.len(), delta, wrap)
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
|
||||
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
|
||||
@@ -356,6 +448,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
|
||||
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
|
||||
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
|
||||
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
|
||||
RowId::Audio => {
|
||||
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
|
||||
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
|
||||
@@ -380,6 +473,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, MouseMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::InvertScroll => toggle(&mut s.invert_scroll, delta, wrap),
|
||||
RowId::Shortcuts => toggle(&mut s.inhibit_shortcuts, delta, wrap),
|
||||
RowId::Stats => {
|
||||
let cur = StatsVerbosity::ALL
|
||||
.iter()
|
||||
@@ -387,6 +482,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||
}
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
// reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights
|
||||
// (BT.2390-flavored simplicity, not libplacebo), then sRGB encode.
|
||||
// params.y = tonemap peak (display-relative, ~= peak_nits / 203).
|
||||
// params.zw = the crop→surface UV scale (frame size / decode-pool size). A Vulkan-Video
|
||||
// pool image is the CODED surface, taller than the picture whenever the height is
|
||||
// not a multiple of the driver's alignment (1080 → 1088); sampling the full 0..1
|
||||
// would drag those padding rows into view — and since encoders fill them by
|
||||
// replicating the last picture line, that reads as the bottom row smeared over the
|
||||
// final few rows. 1.0/1.0 for every path whose image is already crop-sized (dmabuf
|
||||
// imports the planes at the crop over the real stride; D3D11VA clamps in its
|
||||
// VideoProcessor blit).
|
||||
//
|
||||
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
|
||||
#version 450
|
||||
@@ -29,7 +37,7 @@ layout(push_constant) uniform Csc {
|
||||
vec4 r0;
|
||||
vec4 r1;
|
||||
vec4 r2;
|
||||
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
|
||||
vec4 params; // x: mode, y: tonemap peak, zw: crop/pool UV scale
|
||||
} pc;
|
||||
|
||||
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
|
||||
@@ -62,17 +70,22 @@ vec3 srgb_oetf(vec3 c) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Crop to the visible picture: the triangle spans the whole render target, so its 0..1
|
||||
// maps onto the pool surface only after this scale (see params.zw above).
|
||||
vec2 uv = v_uv * pc.params.zw;
|
||||
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
|
||||
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
|
||||
vec2 cuv = v_uv;
|
||||
// textureSize is the POOL's chroma width, which is the space `uv` is already in — so the
|
||||
// offset stays a true quarter-texel whatever the crop.
|
||||
vec2 cuv = uv;
|
||||
int cw = textureSize(u_c, 0).x;
|
||||
if (cw < textureSize(u_y, 0).x) {
|
||||
cuv.x += 0.25 / float(cw);
|
||||
}
|
||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
|
||||
vec3 yuv = vec3(texture(u_y, uv).r, texture(u_c, cuv).rg);
|
||||
vec3 rgb = vec3(
|
||||
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
||||
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
|
||||
//! for 10-bit P010) with the
|
||||
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12 and full-chroma
|
||||
//! NV24, R16/GR1616 for 10-bit P010) with the
|
||||
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
|
||||
//! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import
|
||||
//! is the contract; anything a driver rejects surfaces as a clean error and the caller
|
||||
@@ -14,6 +14,13 @@ use std::os::fd::{BorrowedFd, IntoRawFd as _};
|
||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||
/// `fourcc('P','0','1','0')` — 10-bit 4:2:0, 10 bits MSB-aligned in 16 (the HDR path).
|
||||
const DRM_FORMAT_P010: u32 = 0x3031_3050;
|
||||
/// `fourcc('N','V','2','4')` — 8-bit 4:4:4 semi-planar (full-size interleaved chroma
|
||||
/// plane): the 2-plane full-chroma export a VAAPI HEVC RExt decode can hand over. Same
|
||||
/// R8 + R8G8 views as NV12; the CSC shader keys chroma siting off the plane widths, so
|
||||
/// the full-size plane needs nothing else. (Intel's iHD prefers PACKED 4:4:4 exports —
|
||||
/// AYUV/Y410 — which are single-plane and would need their own CSC arm; those still
|
||||
/// demote to software decode. 10-bit 4:4:4 has no settled dmabuf fourcc at all.)
|
||||
const DRM_FORMAT_NV24: u32 = 0x3432_564e;
|
||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
/// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier.
|
||||
const DRM_FORMAT_MOD_LINEAR: u64 = 0;
|
||||
@@ -92,13 +99,14 @@ pub fn import(
|
||||
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
|
||||
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
|
||||
}
|
||||
let (luma_fmt, chroma_fmt) = match frame.fourcc {
|
||||
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM),
|
||||
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM),
|
||||
other => bail!("hw presenter handles NV12/P010 only (got {other:#x})"),
|
||||
let (luma_fmt, chroma_fmt, chroma_full_res) = match frame.fourcc {
|
||||
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM, false),
|
||||
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM, false),
|
||||
DRM_FORMAT_NV24 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM, true),
|
||||
other => bail!("hw presenter handles NV12/P010/NV24 only (got {other:#x})"),
|
||||
};
|
||||
if frame.planes.len() < 2 {
|
||||
bail!("2-plane 4:2:0 needs 2 planes (got {})", frame.planes.len());
|
||||
bail!("2-plane YCbCr needs 2 planes (got {})", frame.planes.len());
|
||||
}
|
||||
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
|
||||
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
|
||||
@@ -123,16 +131,14 @@ pub fn import(
|
||||
modifier,
|
||||
)
|
||||
.context("luma plane")?;
|
||||
// 4:2:0 subsamples the chroma plane both ways; 4:4:4 (NV24) keeps it full-size.
|
||||
let (cw, ch) = if chroma_full_res {
|
||||
(frame.width, frame.height)
|
||||
} else {
|
||||
(frame.width.div_ceil(2), frame.height.div_ceil(2))
|
||||
};
|
||||
let (chroma_img, chroma_mem) = match plane_image(
|
||||
device,
|
||||
ext_mem_fd,
|
||||
frame.width.div_ceil(2),
|
||||
frame.height.div_ceil(2),
|
||||
chroma_fmt,
|
||||
c.fd,
|
||||
c.offset,
|
||||
c.stride,
|
||||
modifier,
|
||||
device, ext_mem_fd, cw, ch, chroma_fmt, c.fd, c.offset, c.stride, modifier,
|
||||
)
|
||||
.context("chroma plane")
|
||||
{
|
||||
|
||||
@@ -193,6 +193,10 @@ struct StreamState {
|
||||
/// The settings profile this session resolved with, for the stats overlay's first line
|
||||
/// ("which profile am I on?"). `None` = the global defaults, and nothing is shown.
|
||||
profile: Option<String>,
|
||||
/// The latch grid the pump's PhaseReports read (see `session::LatchGrid`), written by
|
||||
/// the 1 Hz present-timing fold. `None` = the session didn't advertise phase lock
|
||||
/// (no present-wait, or an embedder that opted out).
|
||||
latch_grid: Option<Arc<session::LatchGrid>>,
|
||||
/// Live host↔client clock offset handle (None until Connected): loaded per present so
|
||||
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||
@@ -269,6 +273,10 @@ impl StreamState {
|
||||
wake: sdl3::event::EventSender,
|
||||
) -> StreamState {
|
||||
let profile = params.profile.clone();
|
||||
// The presenter's half of phase-locked capture: it writes the latch grid the
|
||||
// pump reads (see `LatchGrid`), so keep the Arc before the params move. `None`
|
||||
// when the session didn't advertise the cap — the 1 Hz fold then skips the work.
|
||||
let latch_grid = params.phase_lock.then(|| params.latch_grid.clone());
|
||||
let handle = session::start(params);
|
||||
let (wake_tx, wake_rx) = async_channel::bounded(2);
|
||||
let pump_rx = handle.frames.clone();
|
||||
@@ -294,6 +302,7 @@ impl StreamState {
|
||||
ready_announced: false,
|
||||
mode_line: String::new(),
|
||||
profile,
|
||||
latch_grid,
|
||||
clock_offset: None,
|
||||
hdr: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
@@ -1458,7 +1467,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.clock_offset
|
||||
.as_ref()
|
||||
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||
for s in presenter.take_presented_samples() {
|
||||
let samples = presenter.take_presented_samples();
|
||||
// Phase-locked capture, the presenter's half: publish this window's latch
|
||||
// grid — a recent TRUE on-glass instant plus the panel period — for the
|
||||
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
|
||||
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
|
||||
// by the display mode's refresh — under arrival-paced MAILBOX a stream
|
||||
// running below the panel rate spaces its presents at k×period, and the
|
||||
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
|
||||
if let Some(grid) = &st.latch_grid {
|
||||
if let Some(last) = samples.last() {
|
||||
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
|
||||
let min_delta = samples
|
||||
.windows(2)
|
||||
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
|
||||
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
|
||||
.min()
|
||||
.unwrap_or(refresh_period);
|
||||
grid.period_ns
|
||||
.store(min_delta.min(refresh_period), Ordering::Relaxed);
|
||||
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
for s in samples {
|
||||
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
@@ -1489,6 +1520,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
let browse_idle = matches!(mode, ModeCtl::Browse(_))
|
||||
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
|
||||
if !presented_video && (resize_scrim || browse_idle) {
|
||||
// The UI owns the screen again: hand the swapchain back to SDR before drawing
|
||||
// it. A finished PQ stream leaves HDR10 live, and nothing else would ever turn
|
||||
// it off — `present` re-evaluates the mode only from a frame's colour
|
||||
// signalling, and these UI presents carry no frame. Guarded inside, so this is
|
||||
// free on every ordinary idle iteration. (Deliberately NOT applied to
|
||||
// `resize_scrim`: that scrim is a mid-stream gap in a session that is still
|
||||
// HDR, and flipping there would rebuild the swapchain twice per resize.)
|
||||
if browse_idle {
|
||||
presenter.leave_hdr(&window)?;
|
||||
}
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
};
|
||||
@@ -1976,8 +2017,27 @@ fn stats_text(
|
||||
}
|
||||
let detailed = verbosity == StatsVerbosity::Detailed;
|
||||
let mut text = if detailed {
|
||||
// The encoder target next to the measured rate is the figure whose absence let the
|
||||
// settings-drop bug ship four releases: "19 Mb/s" alone can't distinguish "the
|
||||
// encoder is capped at 20" from "my 200 Mb/s grant met a cheap scene". `(auto)`
|
||||
// marks an Automatic session — the ABR moves the target by design, so a shifting
|
||||
// number reads as policy, not a broken setting. Omitted against an old host that
|
||||
// never reported a rate.
|
||||
let target = match (s.target_kbps, s.auto_rate) {
|
||||
(0, _) => String::new(),
|
||||
(t, true) => format!(" · target {:.0} Mb/s (auto)", f64::from(t) / 1000.0),
|
||||
(t, false) => format!(" · target {:.0} Mb/s", f64::from(t) / 1000.0),
|
||||
};
|
||||
// The chroma tag mirrors the HDR tag's honesty: `4:4:4→4:2:0` = the session asked
|
||||
// for full chroma and the host resolved 4:2:0 (its policy/capturer/encoder gates
|
||||
// said no) — otherwise the Settings switch's effect is unobservable.
|
||||
let chroma = match (s.asked_444, s.chroma_444) {
|
||||
(_, true) => " · 4:4:4",
|
||||
(true, false) => " · 4:4:4→4:2:0",
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||
"{mode_line} · {:.0} fps · {:.1} Mb/s{target} · {}{}{chroma}",
|
||||
s.fps,
|
||||
s.mbps,
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
@@ -2263,6 +2323,12 @@ mod tests {
|
||||
lost: 3,
|
||||
lost_pct: 0.4,
|
||||
decoder: "vulkan",
|
||||
// Old-host baseline (no reported target, 4:2:0 never asked): the tier
|
||||
// texts stay exactly what they were before the target/chroma elements.
|
||||
target_kbps: 0,
|
||||
auto_rate: false,
|
||||
chroma_444: false,
|
||||
asked_444: false,
|
||||
},
|
||||
PresentedWindow {
|
||||
e2e_p50_ms: 6.4,
|
||||
@@ -2303,6 +2369,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Detailed shows the negotiated encoder target next to the measured rate — the
|
||||
/// figure whose absence let the settings-drop bug ship four releases — tagged
|
||||
/// `(auto)` when the ABR owns it, plus the honest chroma tag when 4:4:4 was asked.
|
||||
#[test]
|
||||
fn detailed_shows_target_and_chroma_resolution() {
|
||||
let (mut s, p) = sample();
|
||||
let line1 = |s: &Stats, v| {
|
||||
stats_text(v, "m", s, &p, false, false, None)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
// Explicit 200 Mb/s honoured, cheap scene: measured AND target both show — the
|
||||
// exact pair a user needs to tell a capped encoder from an idle one.
|
||||
s.target_kbps = 200_000;
|
||||
assert!(line1(&s, StatsVerbosity::Detailed).contains("24.3 Mb/s · target 200 Mb/s · "));
|
||||
// An Automatic session's moving target reads as policy, not a broken setting.
|
||||
(s.target_kbps, s.auto_rate) = (20_000, true);
|
||||
assert!(line1(&s, StatsVerbosity::Detailed).contains("target 20 Mb/s (auto)"));
|
||||
// Normal keeps its old line — the target is a Detailed element.
|
||||
assert!(!line1(&s, StatsVerbosity::Normal).contains("target"));
|
||||
// An old host that never reported a rate shows no target element at all.
|
||||
s.target_kbps = 0;
|
||||
assert!(!line1(&s, StatsVerbosity::Detailed).contains("target"));
|
||||
// 4:4:4 asked and granted…
|
||||
(s.asked_444, s.chroma_444) = (true, true);
|
||||
assert!(line1(&s, StatsVerbosity::Detailed).ends_with("· 4:4:4"));
|
||||
// …vs asked and declined: the downgrade is said out loud, mirroring `HDR→SDR`.
|
||||
s.chroma_444 = false;
|
||||
assert!(line1(&s, StatsVerbosity::Detailed).ends_with("· 4:4:4→4:2:0"));
|
||||
// Unasked stays untagged (4:2:0 is the default — not noise worth a tag).
|
||||
s.asked_444 = false;
|
||||
assert!(!line1(&s, StatsVerbosity::Detailed).contains("4:4:4"));
|
||||
}
|
||||
|
||||
/// Compact omits the latency term until the presenter's first e2e window lands.
|
||||
#[test]
|
||||
fn compact_waits_for_e2e() {
|
||||
|
||||
@@ -248,9 +248,12 @@ impl Presenter {
|
||||
height: v.height,
|
||||
};
|
||||
let ten_bit = f.is_p010();
|
||||
// No crop: `dmabuf::import` already creates the plane images at the frame
|
||||
// size over the surface's real stride, so 0..1 spans exactly the picture.
|
||||
self.record_csc(
|
||||
v.framebuffer,
|
||||
extent,
|
||||
[1.0, 1.0],
|
||||
f.color,
|
||||
if ten_bit { 10 } else { 8 },
|
||||
ten_bit,
|
||||
@@ -322,9 +325,17 @@ impl Presenter {
|
||||
};
|
||||
let ten_bit =
|
||||
f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw();
|
||||
// The one path that samples a surface BIGGER than the picture: FFmpeg's
|
||||
// pool is the coded size (1080 → 1088 rows). Scale the UVs to the visible
|
||||
// crop or the alignment padding — the last picture row, replicated by the
|
||||
// encoder — is stretched into the bottom of the image.
|
||||
self.record_csc(
|
||||
v.framebuffer,
|
||||
extent,
|
||||
[
|
||||
f.width as f32 / f.coded_width as f32,
|
||||
f.height as f32 / f.coded_height as f32,
|
||||
],
|
||||
f.color,
|
||||
if ten_bit { 10 } else { 8 },
|
||||
ten_bit,
|
||||
@@ -625,7 +636,11 @@ impl Presenter {
|
||||
|
||||
/// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen
|
||||
/// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video
|
||||
/// paths — only the plane views bound beforehand differ.
|
||||
/// paths — only the plane views bound beforehand, and `uv_scale`, differ.
|
||||
///
|
||||
/// `extent` is the picture (the framebuffer's own size); `uv_scale` is picture/surface
|
||||
/// per axis, i.e. `[1.0, 1.0]` unless the bound planes are a decode pool allocated
|
||||
/// larger than the picture. See the shader's `params.zw` for why that happens.
|
||||
///
|
||||
/// # Safety
|
||||
/// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point
|
||||
@@ -634,6 +649,7 @@ impl Presenter {
|
||||
&self,
|
||||
framebuffer: vk::Framebuffer,
|
||||
extent: vk::Extent2D,
|
||||
uv_scale: [f32; 2],
|
||||
color: pf_client_core::video::ColorDesc,
|
||||
depth: u8,
|
||||
msb_packed: bool,
|
||||
@@ -702,6 +718,9 @@ impl Presenter {
|
||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||
pc[12] = mode;
|
||||
pc[13] = peak;
|
||||
// Crop: 1.0 unless the source image is a decode pool bigger than the picture.
|
||||
pc[14] = uv_scale[0];
|
||||
pc[15] = uv_scale[1];
|
||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd_buf,
|
||||
@@ -815,19 +834,12 @@ impl Presenter {
|
||||
|
||||
/// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's
|
||||
/// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this).
|
||||
/// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6).
|
||||
/// See [`vkframe_plane_formats`] for the accepted pool formats.
|
||||
fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> {
|
||||
let (luma_fmt, chroma_fmt) = if f.vk_format == vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw()
|
||||
{
|
||||
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
|
||||
} else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() {
|
||||
(
|
||||
vk::Format::R10X6_UNORM_PACK16,
|
||||
vk::Format::R10X6G10X6_UNORM_2PACK16,
|
||||
)
|
||||
} else {
|
||||
let Some((luma_fmt, chroma_fmt)) = vkframe_plane_formats(f.vk_format) else {
|
||||
bail!(
|
||||
"Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)",
|
||||
"Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0 or 4:4:4, \
|
||||
8/10-bit — 3-plane layouts need a third CSC binding)",
|
||||
f.vk_format
|
||||
);
|
||||
};
|
||||
@@ -875,6 +887,36 @@ impl Presenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The (luma, chroma) per-plane view formats for a Vulkan-Video pool format, or `None`
|
||||
/// when this presenter can't sample it (the caller bails; the decoder demotes to
|
||||
/// software — never a black screen).
|
||||
///
|
||||
/// The decision table IS the desktop 4:4:4 display contract, so it's a pure function
|
||||
/// with a test pinning it:
|
||||
/// - 2-plane 4:2:0, 8-bit (NV12-layout) and 10-bit (P010/X6) — the classic pair.
|
||||
/// - 2-plane 4:4:4, 8- and 10-bit — what NVIDIA's Vulkan Video reports for HEVC RExt
|
||||
/// full-chroma decode (semi-planar, like all NVDEC output). The CSC shader already
|
||||
/// handles the full-size chroma plane (its 4:2:0 siting correction self-disables when
|
||||
/// the plane widths match), so accepting the format here is all hardware 4:4:4 needs.
|
||||
/// - 3-plane 4:4:4 stays rejected: the CSC pass samples exactly two planes (luma +
|
||||
/// interleaved chroma); a triplanar pool needs a third binding + shader variant. No
|
||||
/// supported driver reports it for HEVC decode today — revisit when one does.
|
||||
fn vkframe_plane_formats(raw: i32) -> Option<(vk::Format, vk::Format)> {
|
||||
let eight = (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM);
|
||||
let ten = (
|
||||
vk::Format::R10X6_UNORM_PACK16,
|
||||
vk::Format::R10X6G10X6_UNORM_2PACK16,
|
||||
);
|
||||
[
|
||||
(vk::Format::G8_B8R8_2PLANE_420_UNORM, eight),
|
||||
(vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, ten),
|
||||
(vk::Format::G8_B8R8_2PLANE_444_UNORM, eight),
|
||||
(vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16, ten),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(f, planes)| (f.as_raw() == raw).then_some(planes))
|
||||
}
|
||||
|
||||
/// Flatten the 3×vec4 rows for the push-constant block.
|
||||
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
|
||||
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
|
||||
@@ -934,3 +976,42 @@ fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphic
|
||||
unlock(f.frames_ctx as *mut pf_ffvk::AVHWFramesContext, vkf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The pool-format decision table (what this presenter can sample → what stays on the
|
||||
/// hardware path, everything else demotes to software decode). Pinned so a format
|
||||
/// added or dropped here is a deliberate act, not a drive-by.
|
||||
#[test]
|
||||
fn vkframe_pool_format_decision_table() {
|
||||
let eight = Some((vk::Format::R8_UNORM, vk::Format::R8G8_UNORM));
|
||||
let ten = Some((
|
||||
vk::Format::R10X6_UNORM_PACK16,
|
||||
vk::Format::R10X6G10X6_UNORM_2PACK16,
|
||||
));
|
||||
// 2-plane 4:2:0, both depths — the classic pair.
|
||||
let f = |fmt: vk::Format| vkframe_plane_formats(fmt.as_raw());
|
||||
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_420_UNORM), eight);
|
||||
assert_eq!(
|
||||
f(vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16),
|
||||
ten
|
||||
);
|
||||
// 2-plane 4:4:4, both depths — hardware full-chroma (NVIDIA RExt decode). Same
|
||||
// per-plane view formats; the full-size chroma plane is the shader's business.
|
||||
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_444_UNORM), eight);
|
||||
assert_eq!(
|
||||
f(vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16),
|
||||
ten
|
||||
);
|
||||
// 3-plane 4:4:4 and 2-plane 4:2:2: real formats a driver could report, NOT
|
||||
// sampleable by the two-binding CSC — they must demote, not corrupt.
|
||||
assert_eq!(f(vk::Format::G8_B8_R8_3PLANE_444_UNORM), None);
|
||||
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_422_UNORM), None);
|
||||
assert_eq!(f(vk::Format::G16_B16R16_2PLANE_444_UNORM), None);
|
||||
// Garbage never maps.
|
||||
assert_eq!(vkframe_plane_formats(0), None);
|
||||
assert_eq!(vkframe_plane_formats(-1), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,34 @@ impl Presenter {
|
||||
self.hdr_active
|
||||
}
|
||||
|
||||
/// Drop back to the SDR swapchain. A no-op unless HDR10 is actually live, so it is
|
||||
/// cheap to call on every idle iteration (the flip itself rebuilds the CSC pass, the
|
||||
/// video image, the overlay pipe and the swapchain).
|
||||
///
|
||||
/// The console/gamepad UI is SDR content, and it is composited into whatever swapchain
|
||||
/// the last STREAM left behind. [`Presenter::present`] only re-evaluates the mode when a
|
||||
/// frame carries colour signalling, and a UI-only present is `FrameInput::Redraw`, which
|
||||
/// carries none — so once a PQ session ended, the UI kept being written into the HDR10
|
||||
/// swapchain and its sRGB mid-tones were emitted as PQ code points, i.e. near-peak nits.
|
||||
/// That is the "gamepad UI is blown out after disconnecting from an HDR host" report:
|
||||
/// the UI looks right until the first HDR session, and wrong forever after.
|
||||
pub fn leave_hdr(&mut self, window: &sdl3::video::Window) -> Result<()> {
|
||||
if !self.hdr_active {
|
||||
return Ok(());
|
||||
}
|
||||
// Minimized is not just "pointless work": `recreate_swapchain` deliberately keeps
|
||||
// the old swapchain at a zero extent, but `set_hdr_mode` would already have rebuilt
|
||||
// the CSC and overlay pipes against the SDR format — leaving them mismatched against
|
||||
// the live HDR10 swapchain images. [`Presenter::present`] carries the same guard,
|
||||
// which is why the flip could never reach this state before; the mode change just
|
||||
// waits for the window to have a size again.
|
||||
if self.extent.width == 0 || self.extent.height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("stream over — leaving HDR10 so the console UI composites as SDR");
|
||||
self.set_hdr_mode(window, false)
|
||||
}
|
||||
|
||||
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
|
||||
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
|
||||
/// idempotent per distinct value — callers just drain the plane into it.
|
||||
|
||||
@@ -424,6 +424,9 @@ impl Presenter {
|
||||
queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(),
|
||||
pyrowave_decode: pyrowave_ok,
|
||||
video_decode: video_ok,
|
||||
// The phase-lock gate: real on-glass latch stamps exist only when the
|
||||
// present-wait timer runs (see `PresentTimer`).
|
||||
present_timing: present_timer.is_some(),
|
||||
#[cfg(windows)]
|
||||
d3d11_import: win_capable,
|
||||
#[cfg(not(windows))]
|
||||
|
||||
@@ -166,6 +166,12 @@ pub struct NativeClient {
|
||||
/// drained per window by the data-plane pump to feed the adaptive-bitrate controller's decode
|
||||
/// signal. Shared with the pump; see [`DecodeLatAcc`].
|
||||
decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
/// The encoder's CURRENT target bitrate (kbps): seeded with the Welcome resolve, then updated
|
||||
/// by every host `BitrateChanged` ack (the ABR's re-targets, host-side clamps). Where
|
||||
/// [`resolved_bitrate_kbps`](Self::resolved_bitrate_kbps) is the session-start negotiation
|
||||
/// frozen for the ABI, this one moves — it's what a stats HUD should print as "target".
|
||||
/// `0` = an old host that never reported a rate.
|
||||
live_bitrate_kbps: Arc<AtomicU32>,
|
||||
/// Whether the adaptive-bitrate controller is armed for this session (Automatic bitrate and not
|
||||
/// a rate-pinned PyroWave stream) — exposed via [`wants_decode_latency`](Self::wants_decode_latency)
|
||||
/// so an embedder skips the per-frame decode measurement when the controller wouldn't use it.
|
||||
@@ -399,6 +405,8 @@ impl NativeClient {
|
||||
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||
let clock_offset = Arc::new(AtomicI64::new(0));
|
||||
let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default()));
|
||||
// Seeded by the pump from the Welcome (before ready_tx), then follows every ack.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let host = host.to_string();
|
||||
let frame_chan_w = frame_chan.clone();
|
||||
@@ -411,6 +419,7 @@ impl NativeClient {
|
||||
let hot_tids_w = hot_tids.clone();
|
||||
let clock_offset_w = clock_offset.clone();
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -475,6 +484,7 @@ impl NativeClient {
|
||||
hot_tids: hot_tids_w,
|
||||
clock_offset: clock_offset_w,
|
||||
decode_lat: decode_lat_w,
|
||||
live_bitrate: live_bitrate_w,
|
||||
}));
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
@@ -523,6 +533,7 @@ impl NativeClient {
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
live_bitrate_kbps: live_bitrate,
|
||||
// The controller arms exactly when the pump does — all three terms, not two: Automatic
|
||||
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host
|
||||
// echoed the rate it actually configured. Dropping the last term made this
|
||||
@@ -780,6 +791,15 @@ impl NativeClient {
|
||||
self.wants_decode
|
||||
}
|
||||
|
||||
/// The encoder's CURRENT target bitrate (kbps): the Welcome-resolved rate, live-updated by
|
||||
/// every host `BitrateChanged` ack — an Automatic session's ABR re-targets and the host's
|
||||
/// own clamps included. This is the figure a stats HUD should show as "target" next to
|
||||
/// measured throughput (the [`resolved_bitrate_kbps`](Self::resolved_bitrate_kbps) field
|
||||
/// stays the frozen session-start value). `0` = an old host that never reported one.
|
||||
pub fn current_bitrate_kbps(&self) -> u32 {
|
||||
self.live_bitrate_kbps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Report this client's display-latch grid so the host can phase-lock its capture tick
|
||||
/// (design/phase-locked-capture.md; the vsync-aware presenters call this ~1 Hz).
|
||||
/// `next_latch_host_ns` must already be HOST clock — convert with
|
||||
|
||||
@@ -71,6 +71,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
live_bitrate,
|
||||
..
|
||||
} = args;
|
||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||
@@ -80,6 +81,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||
// Same discipline for the live encoder target: the Welcome resolve is the starting truth
|
||||
// (0 against an old host that reports none); every BitrateChanged ack moves it from there.
|
||||
live_bitrate.store(negotiated.bitrate_kbps, Ordering::Relaxed);
|
||||
// Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
|
||||
// reset its staleness counters and re-arm the clock-based jump-to-live detector.
|
||||
let clock_gen = Arc::new(AtomicU32::new(0));
|
||||
@@ -134,6 +138,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
mode_slot,
|
||||
probe: probe.clone(),
|
||||
bitrate_ack: bitrate_ack.clone(),
|
||||
live_bitrate,
|
||||
recovery_kf: recovery_kf.clone(),
|
||||
clock_offset: clock_offset.clone(),
|
||||
clock_gen: clock_gen.clone(),
|
||||
|
||||
@@ -16,6 +16,9 @@ pub(super) struct ControlTask {
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
|
||||
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
/// Outbound decode-recovery KEYFRAME asks, counted here because this is the one choke point
|
||||
/// every emitter funnels through (embedder, `note_frame_index`, the pump's own asks) — the
|
||||
/// pump drains the count per report window as the ABR's recovery signal.
|
||||
@@ -44,6 +47,7 @@ impl ControlTask {
|
||||
mode_slot,
|
||||
probe,
|
||||
bitrate_ack,
|
||||
live_bitrate,
|
||||
recovery_kf,
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
@@ -157,6 +161,11 @@ impl ControlTask {
|
||||
kbps = ack.bitrate_kbps,
|
||||
"host re-targeted encoder bitrate"
|
||||
);
|
||||
// 0 would be a nonsense ack (the controller ignores it too) — don't
|
||||
// let it wipe the HUD's live target.
|
||||
if ack.bitrate_kbps > 0 {
|
||||
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
|
||||
}
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{HdrMeta, HidOutput};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -73,6 +73,9 @@ pub(crate) struct WorkerArgs {
|
||||
/// Decode-stage latency samples from the embedder (see [`NativeClient::decode_lat`]): the pump
|
||||
/// drains a window mean into the adaptive-bitrate controller's decode signal.
|
||||
pub(crate) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
/// The live encoder-target mirror (see [`NativeClient::live_bitrate_kbps`]): the worker seeds
|
||||
/// it from the Welcome; the control task updates it on every `BitrateChanged` ack.
|
||||
pub(crate) live_bitrate: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||
|
||||
@@ -326,14 +326,15 @@ pub(super) async fn negotiate(
|
||||
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
|
||||
// gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
|
||||
// unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
|
||||
// the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
|
||||
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
|
||||
// unconditionally). On WINDOWS the IDD-push path CAN too, at either depth: an SDR session
|
||||
// passes the BGRA ring slot straight through and an HDR one converts the FP16 desktop to
|
||||
// packed 10-bit BT.2020 PQ RGB — both skip the subsampling converters. Only a backend that
|
||||
// ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
|
||||
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
|
||||
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
|
||||
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
|
||||
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). HDR no longer costs the chroma: 10-bit
|
||||
// 4:4:4 is HEVC Main 4:4:4 10, which is what this resolves to. (Replaces the old
|
||||
// `single_process` gate — single-process is now the only topology, and 4:4:4 routed to DDA,
|
||||
// which was removed.)
|
||||
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
||||
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
||||
// gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS).
|
||||
|
||||
@@ -45,8 +45,9 @@ captured input, switch mouse mode, disconnect — are in
|
||||
|
||||
**Compact** is a one-line pill (fps · end-to-end ms · Mb/s, plus a loss flag when frames are being
|
||||
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage
|
||||
breakdown everywhere; on Linux/Windows it also adds the decode path and an HDR tag, on Android the
|
||||
decoder plus the full codec/bit-depth/colour line, and on iOS/tvOS the excluded OS present floor.
|
||||
breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path,
|
||||
an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and
|
||||
on iOS/tvOS the excluded OS present floor.
|
||||
You can also set the level a stream starts at in each client's
|
||||
[Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view.
|
||||
|
||||
@@ -60,7 +61,7 @@ Every client reports the same measurements, but each family lays them out a litt
|
||||
differently. Linux · Windows · Steam Deck:
|
||||
|
||||
```
|
||||
1920×1080@120 · 120 fps · 24.3 Mb/s · vulkan · HDR
|
||||
1920×1080@120 · 120 fps · 24.3 Mb/s · target 30 Mb/s (auto) · vulkan · HDR
|
||||
e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms
|
||||
host: queue 0.6 · encode 1.8 · xfer 0.2 · pace 0.5 ms
|
||||
lost 3 (2.4%)
|
||||
@@ -90,10 +91,18 @@ lost 3 (2.4%)
|
||||
```
|
||||
|
||||
- **Line 1 — the stream.** Resolution@refresh, frames received per second, and the
|
||||
received video bitrate (goodput — FEC overhead not counted). Linux/Windows append the
|
||||
decode path and an [HDR](/docs/hdr) tag (`HDR`, or `HDR→SDR` when a PQ stream is tone-mapped onto
|
||||
an SDR screen); Android puts its decoder and the negotiated codec, bit depth, colour and
|
||||
chroma on rows of their own underneath; the Apple clients don't report a codec at all.
|
||||
received video bitrate (goodput — FEC overhead not counted). Linux/Windows follow the
|
||||
measured rate with `target N Mb/s` — what the host's encoder is currently *allowed* to
|
||||
produce — so a quiet desktop under a large grant (measured far below target) reads
|
||||
differently from an encoder pinned at its cap (measured hugging the target). `(auto)`
|
||||
means the [Automatic bitrate](/docs/client-settings#bitrate) controller owns the target
|
||||
and moves it with network conditions; no target at all means an older host that doesn't
|
||||
report one. Then the decode path, an [HDR](/docs/hdr) tag (`HDR`, or `HDR→SDR` when a PQ
|
||||
stream is tone-mapped onto an SDR screen), and — when you asked for
|
||||
[full chroma](/docs/client-settings) — the resolved chroma: `4:4:4` when the host
|
||||
granted it, `4:4:4→4:2:0` when it couldn't. Android puts its decoder and the negotiated
|
||||
codec, bit depth, colour and chroma on rows of their own underneath; the Apple clients
|
||||
don't report a codec at all.
|
||||
If the session resolved to a [settings profile](/docs/profiles-and-links), its name closes this
|
||||
line. On **Android** a `⚠ panel NN Hz` warning joins it whenever the device's panel is refreshing
|
||||
*below* the stream's rate — the tell for a phone or TV governor that ignored the requested mode,
|
||||
|
||||
@@ -31,9 +31,20 @@ if (-not (Test-Path $appWeb)) {
|
||||
}
|
||||
|
||||
Write-Host "swapping $appWeb\.output (stopping the PunktfunkHost service) ..."
|
||||
$dst = Join-Path $appWeb '.output'
|
||||
& net stop PunktfunkHost | Out-Null
|
||||
try {
|
||||
Remove-Item (Join-Path $appWeb '.output') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
# The removal MUST succeed before the copy. A merge of two builds is not a degraded
|
||||
# install, it is a dead one: Nitro's entry.mjs imports its siblings by content hash, so a
|
||||
# stale chunks/_ next to a new entry.mjs makes every page 200 with a bun ResolveMessage
|
||||
# body instead of the app. Observed on .173 2026-07-31 (an older task-based copy of this
|
||||
# script could not unlock the files under the supervised-child host, its
|
||||
# -ErrorAction SilentlyContinue swallowed that, and the console served a JSON error for
|
||||
# hours while the probe below reported success).
|
||||
Remove-Item $dst -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $dst) {
|
||||
throw "could not remove $dst (files still locked - is another bun/host still running?). Refusing to copy over it: a mixed .output serves errors, not the console."
|
||||
}
|
||||
Copy-Item (Join-Path $web '.output') -Destination $appWeb -Recurse -Force
|
||||
}
|
||||
finally {
|
||||
@@ -43,14 +54,20 @@ finally {
|
||||
# The console serves HTTPS-only (PUNKTFUNK_UI_SECURE=1, the host's own cert) - probe with curl.exe
|
||||
# (-k for the self-signed cert; Invoke-WebRequest under Windows PowerShell 5.1, which this script
|
||||
# runs under, has no -SkipCertificateCheck), retrying while the service/bun cold-starts.
|
||||
$code = $null
|
||||
#
|
||||
# The BODY is the check, not the status code: a bun that started but cannot resolve its own
|
||||
# chunks answers 200 with a ResolveMessage JSON, so a code-only probe reports a healthy
|
||||
# console that serves nothing but an error (exactly how the .173 breakage stayed invisible).
|
||||
$body = $null
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
Start-Sleep 2
|
||||
$code = & curl.exe -sk -o NUL -w '%{http_code}' --max-time 5 'https://127.0.0.1:47992/login' 2>$null
|
||||
if ($code -eq '200') { break }
|
||||
$body = & curl.exe -sk --max-time 5 'https://127.0.0.1:47992/login' 2>$null
|
||||
if ($body -match '<html|<!DOCTYPE html') { break }
|
||||
}
|
||||
if ($code -eq '200') {
|
||||
Write-Host "DONE - web /login -> HTTP $code"
|
||||
if ($body -match '<html|<!DOCTYPE html') {
|
||||
Write-Host "DONE - the console serves the app (/login returned HTML)"
|
||||
} elseif ($body -match 'Cannot find module|ResolveMessage') {
|
||||
Write-Error "BROKEN - /login answered with a bun module-resolution error, i.e. .output is inconsistent: $body"
|
||||
} else {
|
||||
Write-Warning "console swapped but /login did not return 200 yet (last: $code) - check %ProgramData%\punktfunk\logs\web.log"
|
||||
Write-Warning "console swapped but /login did not serve HTML yet - check %ProgramData%\punktfunk\logs\web.log. Last body: $body"
|
||||
}
|
||||
|
||||
+4
-3
@@ -35,8 +35,9 @@ WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
|
||||
# loopback :47990, no token (a token is mandatory for non-loopback binds).
|
||||
```
|
||||
|
||||
If the host runs with `--mgmt-token`, set it under **Settings → API token** (stored in
|
||||
`localStorage`, sent as `Authorization: Bearer …` by the orval fetcher).
|
||||
The management token is **server-side only** — set `PUNKTFUNK_MGMT_TOKEN` in the console's
|
||||
environment and the BFF injects it when proxying (`server/routes/api/[...].ts`). It never reaches
|
||||
the browser, so there is no token field in the UI; the browser only ever holds the session cookie.
|
||||
|
||||
## Build & run (Nitro + Bun)
|
||||
|
||||
@@ -115,7 +116,7 @@ src/
|
||||
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
|
||||
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
|
||||
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
|
||||
query-state.tsx loading/error wrapper (incl. 401 → "set a token")
|
||||
query-state.tsx loading/error wrapper (401 → the session is gone, re-login)
|
||||
api/
|
||||
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
|
||||
gen/ GENERATED react-query hooks + models (orval)
|
||||
|
||||
+102
-10
@@ -6,7 +6,6 @@
|
||||
"nav_dashboard": "Übersicht",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtuelle Anzeigen",
|
||||
"nav_clients": "Gekoppelte Geräte",
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_plugins": "Plugins",
|
||||
@@ -14,7 +13,49 @@
|
||||
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||
"plugin_retry": "Erneut versuchen",
|
||||
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||
"nav_automation": "Automatisierung",
|
||||
"automation_title": "Automatisierung",
|
||||
"automation_subtitle": "Führe einen Befehl aus oder rufe einen Webhook auf, wenn auf diesem Host etwas passiert.",
|
||||
"automation_hooks_title": "Ereignis-Hooks",
|
||||
"automation_add": "Hook hinzufügen",
|
||||
"automation_empty": "Noch keine Hooks. Füge einen hinzu, um etwas auszuführen, wenn ein Stream startet, ein Gerät koppelt oder ein Spiel endet.",
|
||||
"automation_edit": "Hook bearbeiten",
|
||||
"automation_delete": "Hook löschen",
|
||||
"automation_delete_confirm": "Diesen Hook löschen?",
|
||||
"automation_unsaved": "Nicht gespeicherte Änderungen",
|
||||
"automation_saved": "Automatisierung gespeichert",
|
||||
"automation_save_failed": "Die Automatisierung konnte nicht gespeichert werden.",
|
||||
"automation_debounce_badge": "mind. {ms} ms Abstand",
|
||||
"automation_hook_title": "Hook",
|
||||
"automation_hook_help": "Wähle das Ereignis und dann, was passieren soll. Ein abschließendes .* trifft alle Ereignisse dieser Domäne.",
|
||||
"automation_hook_save": "Fertig",
|
||||
"automation_field_on": "Wenn",
|
||||
"automation_field_on_help": "Das Ereignis, das diesen Hook auslöst — dieselben Namen, die der Host auf seinem Event-Stream veröffentlicht.",
|
||||
"automation_field_action": "Dann",
|
||||
"automation_action_run": "Befehl ausführen",
|
||||
"automation_action_webhook": "Webhook aufrufen",
|
||||
"automation_action_run_help": "Läuft losgelöst als Host-Benutzer, mit dem Ereignis-JSON auf stdin und PF_EVENT_* in der Umgebung.",
|
||||
"automation_action_webhook_help": "Das Ereignis-JSON wird per POST an diese URL geschickt.",
|
||||
"automation_field_hmac": "HMAC-Schlüsseldatei (optional)",
|
||||
"automation_field_hmac_help": "Eine private, dem Betreiber gehörende Datei mit dem Signaturschlüssel. Die Anfrage trägt X-Punktfunk-Signature, damit der Empfänger prüfen kann, dass sie wirklich von diesem Host kommt.",
|
||||
"automation_field_filter": "Nur für ein bestimmtes Gerät oder Spiel",
|
||||
"automation_filter_client": "Gerätename",
|
||||
"automation_filter_app": "Spiel-/App-ID",
|
||||
"automation_field_debounce": "Mindestabstand (ms)",
|
||||
"automation_field_timeout": "Zeitlimit (s)",
|
||||
"automation_confirm_title": "Automatisierung speichern?",
|
||||
"automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.",
|
||||
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
|
||||
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
|
||||
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
|
||||
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
|
||||
"stats_delete_failed": "Diese Aufzeichnung konnte nicht gelöscht werden.",
|
||||
"stats_download_failed": "Diese Aufzeichnung konnte nicht heruntergeladen werden.",
|
||||
"games_end_failed": "Das Spiel konnte nicht beendet werden.",
|
||||
"action_stop_failed": "Die Sitzung konnte nicht beendet werden.",
|
||||
"action_idr_failed": "Es konnte kein Keyframe angefordert werden.",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_close_menu": "Menü schließen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
@@ -25,14 +66,47 @@
|
||||
"status_sessions_active": "{count} aktiv",
|
||||
"status_no_session": "Keine aktive Sitzung",
|
||||
"status_paired_count": "Gekoppelte Geräte",
|
||||
"status_pin_waiting": "Wartet",
|
||||
"status_pin_none": "Keine",
|
||||
"status_pin_pending": "Kopplungs-PIN ausstehend",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Auflösung",
|
||||
"stream_fps": "Bildrate",
|
||||
"stream_first_frame": "Erstes Bild",
|
||||
"stream_last_resize": "Letzte Größenänderung",
|
||||
"stream_packet_size": "Paketgröße",
|
||||
"stream_min_fec": "FEC-Minimum",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"activity_title": "Letzte Aktivität",
|
||||
"activity_empty": "Noch nichts — Ereignisse erscheinen hier, sobald sie auf dem Host passieren.",
|
||||
"activity_client_connected": "Verbunden",
|
||||
"activity_client_disconnected": "Getrennt",
|
||||
"activity_session_started": "Sitzung gestartet",
|
||||
"activity_session_ended": "Sitzung beendet",
|
||||
"activity_stream_started": "Stream gestartet",
|
||||
"activity_stream_stopped": "Stream gestoppt",
|
||||
"activity_game_running": "Spiel läuft",
|
||||
"activity_game_exited": "Spiel beendet",
|
||||
"activity_pairing_pending": "Kopplung angefragt",
|
||||
"activity_pairing_completed": "Gekoppelt",
|
||||
"activity_pairing_denied": "Kopplung abgelehnt",
|
||||
"activity_display_created": "Anzeige erstellt",
|
||||
"activity_display_released": "Anzeige freigegeben",
|
||||
"activity_library_changed": "Bibliothek geändert",
|
||||
"activity_update_available": "Update verfügbar",
|
||||
"activity_update_applied": "Update angewendet",
|
||||
"activity_plugins_changed": "Plugins geändert",
|
||||
"activity_store_changed": "Store geändert",
|
||||
"activity_host_started": "Host gestartet",
|
||||
"activity_host_stopping": "Host wird beendet",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"connect_title": "Gerät verbinden",
|
||||
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
|
||||
"connect_address": "Host-Adresse",
|
||||
"connect_link": "Deep-Link",
|
||||
"connect_copy": "Kopieren",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Betriebssystem",
|
||||
@@ -60,7 +134,8 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
|
||||
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
@@ -90,6 +165,7 @@
|
||||
"display_state_lingering": "Wird gehalten",
|
||||
"display_state_pinned": "Angeheftet",
|
||||
"display_release_btn": "Freigeben",
|
||||
"display_refresh_failed": "Aktualisierung vom Host fehlgeschlagen — es werden die zuletzt bekannten Einstellungen gezeigt. Deine Änderungen bleiben erhalten.",
|
||||
"display_release_all": "Alle gehaltenen freigeben",
|
||||
"display_expires_in": "Abbau in {sec}s",
|
||||
"display_sessions": "{count} streamend",
|
||||
@@ -97,6 +173,7 @@
|
||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||
"display_arrange_save": "Anordnung speichern",
|
||||
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||
"display_preset_apply_named": "Voreinstellung {name} anwenden",
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
@@ -144,11 +221,8 @@
|
||||
"display_all_saved": "Alle Änderungen gespeichert",
|
||||
"display_revert": "Änderungen verwerfen",
|
||||
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerabdruck",
|
||||
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||
"pairing_title": "Kopplung",
|
||||
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
|
||||
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
|
||||
@@ -186,6 +260,7 @@
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Eigene",
|
||||
"library_add_title": "Eigenes Spiel hinzufügen",
|
||||
"library_edit_overwrites": "Beim Speichern wird dieser Eintrag durch das ersetzt, was in diesem Formular steht. Vorbereitungs-/Undo-Befehle und Erkennungs-Hinweise, die außerhalb der Konsole gesetzt wurden, erscheinen hier nicht und gehen verloren.",
|
||||
"library_edit_title": "Eigenes Spiel bearbeiten",
|
||||
"library_add_button": "Eigenes Spiel hinzufügen",
|
||||
"library_field_title": "Titel",
|
||||
@@ -209,6 +284,16 @@
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_owned_by": "über {provider}",
|
||||
"library_providers_title": "Von Plugins synchronisiert",
|
||||
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
|
||||
"library_provider_count": "{count} Einträge",
|
||||
"library_provider_filter": "Nur diese zeigen",
|
||||
"library_provider_show_all": "Alle zeigen",
|
||||
"library_provider_purge": "Einträge dieses Anbieters entfernen",
|
||||
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
|
||||
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
|
||||
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
@@ -216,15 +301,10 @@
|
||||
"library_delete": "Löschen",
|
||||
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||
"settings_language": "Sprache",
|
||||
"settings_save": "Speichern",
|
||||
"settings_saved": "Gespeichert.",
|
||||
"common_loading": "Wird geladen…",
|
||||
"common_error": "Etwas ist schiefgelaufen.",
|
||||
"common_retry": "Erneut versuchen",
|
||||
"common_yes": "Ja",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
|
||||
"login_title": "Anmelden",
|
||||
@@ -247,6 +327,7 @@
|
||||
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||
"logs_download": "Logs herunterladen",
|
||||
"logs_stalled": "Log-Abruf fehlgeschlagen — die zuletzt empfangenen Zeilen bleiben sichtbar.",
|
||||
"logs_share": "Logs teilen",
|
||||
"logs_copy": "Logs in die Zwischenablage kopieren",
|
||||
"logs_copied": "Logs in die Zwischenablage kopiert",
|
||||
@@ -265,6 +346,7 @@
|
||||
"stats_kind_native": "Nativ",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_window": "Es werden die letzten {count} Messpunkte gezeigt. Die gespeicherte Aufzeichnung enthält alles.",
|
||||
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
|
||||
"stats_latency_title": "Latenz nach Stufe",
|
||||
"stats_latency_axis": "µs",
|
||||
@@ -313,6 +395,7 @@
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_all_sources_failed": "Kein Katalog konnte geladen werden ({sources}). Der Store ist womöglich nicht leer — sieh im Tab „Quellen“ nach.",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
@@ -340,6 +423,7 @@
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_version_unknown": "Version unbekannt",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
@@ -369,6 +453,7 @@
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_password": "Konsolen-Passwort",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
@@ -387,12 +472,16 @@
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_spec_password": "Konsolen-Passwort",
|
||||
"store_spec_password_help": "Für ungeprüften Code wird das Passwort erneut gebraucht — eine Browser-Sitzung allein reicht dafür nicht.",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_lost": "Dieser Vorgang ist nicht mehr auffindbar",
|
||||
"store_job_lost_hint": "Der Host wurde währenddessen neu gestartet. Im Tab „Installiert“ siehst du, ob er durchgelaufen ist.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
@@ -410,6 +499,8 @@
|
||||
"games_state_exited": "Beendet",
|
||||
"games_state_grace": "Wartet auf Client",
|
||||
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
|
||||
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?",
|
||||
"action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?",
|
||||
"games_end_now": "Jetzt beenden",
|
||||
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
|
||||
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
|
||||
@@ -467,6 +558,7 @@
|
||||
"update_apply_wrong_password": "Falsches Passwort.",
|
||||
"update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.",
|
||||
"update_apply_working": "Starte…",
|
||||
"update_apply_give_up": "Nicht weiter warten",
|
||||
"update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).",
|
||||
"update_applying_title": "Aktualisiere auf {version}",
|
||||
"update_stage_downloading": "Lade den Installer… {pct}%",
|
||||
|
||||
+102
-10
@@ -6,10 +6,51 @@
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_host": "Host",
|
||||
"nav_displays": "Virtual displays",
|
||||
"nav_clients": "Paired clients",
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_automation": "Automation",
|
||||
"automation_title": "Automation",
|
||||
"automation_subtitle": "Run a command, or call a webhook, when something happens on this host.",
|
||||
"automation_hooks_title": "Event hooks",
|
||||
"automation_add": "Add hook",
|
||||
"automation_empty": "No hooks yet. Add one to run something when a stream starts, a client pairs, or a game exits.",
|
||||
"automation_edit": "Edit hook",
|
||||
"automation_delete": "Delete hook",
|
||||
"automation_delete_confirm": "Delete this hook?",
|
||||
"automation_unsaved": "Unsaved changes",
|
||||
"automation_saved": "Automation saved",
|
||||
"automation_save_failed": "Could not save the automation.",
|
||||
"automation_debounce_badge": "min {ms} ms apart",
|
||||
"automation_hook_title": "Hook",
|
||||
"automation_hook_help": "Pick the event, then what should happen. A trailing .* matches every event in that domain.",
|
||||
"automation_hook_save": "Done",
|
||||
"automation_field_on": "When",
|
||||
"automation_field_on_help": "The event that fires this hook — the same names the host publishes on its event stream.",
|
||||
"automation_field_action": "Then",
|
||||
"automation_action_run": "Run a command",
|
||||
"automation_action_webhook": "Call a webhook",
|
||||
"automation_action_run_help": "Runs detached, as the host user, with the event JSON on stdin and PF_EVENT_* in the environment.",
|
||||
"automation_action_webhook_help": "The event JSON is POSTed to this URL.",
|
||||
"automation_field_hmac": "HMAC secret file (optional)",
|
||||
"automation_field_hmac_help": "A private, operator-owned file holding the signing secret. The request carries X-Punktfunk-Signature so the receiver can verify it really came from this host.",
|
||||
"automation_field_filter": "Only for a specific client or game",
|
||||
"automation_filter_client": "Client name",
|
||||
"automation_filter_app": "Game / app id",
|
||||
"automation_field_debounce": "Minimum gap (ms)",
|
||||
"automation_field_timeout": "Timeout (s)",
|
||||
"automation_confirm_title": "Save automation?",
|
||||
"automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.",
|
||||
"library_delete_failed": "Could not delete this entry.",
|
||||
"gpu_apply_failed": "Could not change the GPU preference.",
|
||||
"stats_start_failed": "Could not start the capture.",
|
||||
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
|
||||
"stats_delete_failed": "Could not delete this recording.",
|
||||
"stats_download_failed": "Could not download this recording.",
|
||||
"games_end_failed": "Could not end the game.",
|
||||
"action_stop_failed": "Could not stop the session.",
|
||||
"action_idr_failed": "Could not request a keyframe.",
|
||||
"nav_settings": "Settings",
|
||||
"nav_close_menu": "Close menu",
|
||||
"nav_more": "More",
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
@@ -25,14 +66,47 @@
|
||||
"status_sessions_active": "{count} active",
|
||||
"status_no_session": "No active session",
|
||||
"status_paired_count": "Paired clients",
|
||||
"status_pin_waiting": "Waiting",
|
||||
"status_pin_none": "None",
|
||||
"status_pin_pending": "Pairing PIN pending",
|
||||
"stream_codec": "Codec",
|
||||
"stream_resolution": "Resolution",
|
||||
"stream_fps": "Frame rate",
|
||||
"stream_first_frame": "First frame",
|
||||
"stream_last_resize": "Last resize",
|
||||
"stream_packet_size": "Packet size",
|
||||
"stream_min_fec": "FEC floor",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"activity_title": "Recent activity",
|
||||
"activity_empty": "Nothing yet — events show up here as they happen on the host.",
|
||||
"activity_client_connected": "Connected",
|
||||
"activity_client_disconnected": "Disconnected",
|
||||
"activity_session_started": "Session started",
|
||||
"activity_session_ended": "Session ended",
|
||||
"activity_stream_started": "Stream started",
|
||||
"activity_stream_stopped": "Stream stopped",
|
||||
"activity_game_running": "Game running",
|
||||
"activity_game_exited": "Game exited",
|
||||
"activity_pairing_pending": "Pairing requested",
|
||||
"activity_pairing_completed": "Paired",
|
||||
"activity_pairing_denied": "Pairing denied",
|
||||
"activity_display_created": "Display created",
|
||||
"activity_display_released": "Display released",
|
||||
"activity_library_changed": "Library changed",
|
||||
"activity_update_available": "Update available",
|
||||
"activity_update_applied": "Update applied",
|
||||
"activity_plugins_changed": "Plugins changed",
|
||||
"activity_store_changed": "Store changed",
|
||||
"activity_host_started": "Host started",
|
||||
"activity_host_stopping": "Host stopping",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"connect_title": "Connect a device",
|
||||
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
|
||||
"connect_address": "Host address",
|
||||
"connect_link": "Deep link",
|
||||
"connect_copy": "Copy",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Operating system",
|
||||
@@ -60,7 +134,8 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_conflicts_title": "Another game-streaming server is running on this machine",
|
||||
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
@@ -90,6 +165,7 @@
|
||||
"display_state_lingering": "Lingering",
|
||||
"display_state_pinned": "Pinned",
|
||||
"display_release_btn": "Release",
|
||||
"display_refresh_failed": "Could not refresh from the host — showing the last known settings. Your edits are safe.",
|
||||
"display_release_all": "Release all kept",
|
||||
"display_expires_in": "tears down in {sec}s",
|
||||
"display_sessions": "{count} streaming",
|
||||
@@ -97,6 +173,7 @@
|
||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||
"display_arrange_save": "Save arrangement",
|
||||
"display_custom_desc": "Set every option yourself.",
|
||||
"display_preset_apply_named": "Apply preset {name}",
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
@@ -144,11 +221,8 @@
|
||||
"display_all_saved": "All changes saved",
|
||||
"display_revert": "Discard changes",
|
||||
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
"clients_fingerprint": "Fingerprint",
|
||||
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
|
||||
"pairing_title": "Pairing",
|
||||
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
|
||||
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
|
||||
@@ -186,6 +260,7 @@
|
||||
"library_store_steam": "Steam",
|
||||
"library_store_custom": "Custom",
|
||||
"library_add_title": "Add a custom game",
|
||||
"library_edit_overwrites": "Saving replaces this entry with what's in this form. Prep/undo commands and detection hints set outside the console aren't shown here and will be cleared.",
|
||||
"library_edit_title": "Edit custom game",
|
||||
"library_add_button": "Add custom game",
|
||||
"library_field_title": "Title",
|
||||
@@ -209,6 +284,16 @@
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_owned_by": "via {provider}",
|
||||
"library_providers_title": "Synced by plugins",
|
||||
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
|
||||
"library_provider_count": "{count} entries",
|
||||
"library_provider_filter": "Show only these",
|
||||
"library_provider_show_all": "Show all",
|
||||
"library_provider_purge": "Remove this provider's entries",
|
||||
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
|
||||
"library_provider_purged": "Removed the entries synced by “{provider}”.",
|
||||
"library_provider_purge_failed": "Could not remove this provider's entries.",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
@@ -216,15 +301,10 @@
|
||||
"library_delete": "Delete",
|
||||
"library_delete_confirm": "Delete this custom game? This can't be undone.",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||
"settings_language": "Language",
|
||||
"settings_save": "Save",
|
||||
"settings_saved": "Saved.",
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Something went wrong.",
|
||||
"common_retry": "Retry",
|
||||
"common_yes": "Yes",
|
||||
"common_cancel": "Cancel",
|
||||
"common_unauthorized": "Session expired — redirecting to sign in…",
|
||||
"login_title": "Sign in",
|
||||
@@ -247,6 +327,7 @@
|
||||
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||
"logs_download": "Download logs",
|
||||
"logs_stalled": "Log polling failed — showing the last lines received.",
|
||||
"logs_share": "Share logs",
|
||||
"logs_copy": "Copy logs to clipboard",
|
||||
"logs_copied": "Logs copied to clipboard",
|
||||
@@ -265,6 +346,7 @@
|
||||
"stats_kind_native": "Native",
|
||||
"stats_kind_gamestream": "GameStream",
|
||||
"stats_live_title": "Live",
|
||||
"stats_live_window": "Showing the last {count} samples. The saved recording keeps everything.",
|
||||
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
|
||||
"stats_latency_title": "Latency by stage",
|
||||
"stats_latency_axis": "µs",
|
||||
@@ -313,6 +395,7 @@
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_all_sources_failed": "Could not fetch any catalog ({sources}). The store may not be empty — check the Sources tab.",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
@@ -340,6 +423,7 @@
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_version_unknown": "version unknown",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
@@ -369,6 +453,7 @@
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_password": "Console password",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
@@ -387,12 +472,16 @@
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_spec_password": "Console password",
|
||||
"store_spec_password_help": "Running unreviewed code needs the password again — a browser session on its own can't do this.",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_lost": "Lost track of this job",
|
||||
"store_job_lost_hint": "The host restarted while it ran. Check the Installed tab to see whether it finished.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
@@ -410,6 +499,8 @@
|
||||
"games_state_exited": "Ended",
|
||||
"games_state_grace": "Waiting for client",
|
||||
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
|
||||
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?",
|
||||
"action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?",
|
||||
"games_end_now": "End now",
|
||||
"session_game_title": "When a game or a session ends",
|
||||
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
|
||||
@@ -467,6 +558,7 @@
|
||||
"update_apply_wrong_password": "Wrong password.",
|
||||
"update_apply_throttled": "Too many attempts — wait a moment and try again.",
|
||||
"update_apply_working": "Starting…",
|
||||
"update_apply_give_up": "Stop waiting",
|
||||
"update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).",
|
||||
"update_applying_title": "Updating to {version}",
|
||||
"update_stage_downloading": "Downloading the installer… {pct}%",
|
||||
|
||||
@@ -28,6 +28,18 @@ const ws = import.meta._websocket
|
||||
? wsAdapter(nitroApp.h3App.websocket)
|
||||
: undefined;
|
||||
|
||||
// The socket peer, handed to the app as a trusted header.
|
||||
//
|
||||
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
|
||||
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
|
||||
// per-peer decision collapses onto one shared bucket. That silently defeated the login
|
||||
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
|
||||
// (and, since the update-apply route shares that budget, locked out host updates too).
|
||||
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
|
||||
// Any inbound copy is deleted first, so a client cannot forge it.
|
||||
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
|
||||
const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
|
||||
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
||||
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
||||
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
||||
@@ -36,11 +48,41 @@ const tls =
|
||||
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
||||
: undefined;
|
||||
|
||||
// Half-configured TLS is not a warning, it is a refusal.
|
||||
//
|
||||
// Two silent failures hide here, and both end with the operator staring at a console that looks
|
||||
// fine. One path set and the other missing drops to plain HTTP — the login password then crosses
|
||||
// the LAN in the clear on a server the operator believes is TLS. And PUNKTFUNK_UI_SECURE without
|
||||
// TLS marks the session cookie Secure, which a browser refuses to store over http://, so login
|
||||
// "succeeds" and every request after it is unauthenticated, forever.
|
||||
//
|
||||
// Neither state can serve a working console, so exiting is strictly better than serving a broken
|
||||
// one: a supervisor logs the reason and the operator sees a stopped service instead of a subtly
|
||||
// wrong one.
|
||||
const secureFlag = /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "");
|
||||
if (Boolean(certPath) !== Boolean(keyPath)) {
|
||||
console.error(
|
||||
`punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` +
|
||||
"TLS needs BOTH. Refusing to start rather than serve the login password in the clear.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!tls && secureFlag) {
|
||||
console.error(
|
||||
"punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " +
|
||||
"cookie would be marked Secure and dropped by the browser over http://, so login could " +
|
||||
"never stick. Refusing to start — set PUNKTFUNK_UI_TLS_CERT/_KEY, or unset PUNKTFUNK_UI_SECURE.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: process.env.NITRO_PORT || process.env.PORT || 3000,
|
||||
host: process.env.NITRO_HOST || process.env.HOST,
|
||||
idleTimeout:
|
||||
Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined,
|
||||
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
|
||||
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
|
||||
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
|
||||
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
|
||||
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
||||
tls,
|
||||
websocket: import.meta._websocket ? ws.websocket : undefined,
|
||||
@@ -53,10 +95,15 @@ const server = Bun.serve({
|
||||
if (req.body) {
|
||||
body = await req.arrayBuffer();
|
||||
}
|
||||
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete(PEER_IP_HEADER);
|
||||
const peer = server.requestIP(req)?.address;
|
||||
if (peer) headers.set(PEER_IP_HEADER, peer);
|
||||
return nitroApp.localFetch(url.pathname + url.search, {
|
||||
host: url.hostname,
|
||||
protocol: url.protocol,
|
||||
headers: req.headers,
|
||||
headers,
|
||||
method: req.method,
|
||||
redirect: req.redirect,
|
||||
body,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dev": "vite dev --port 47992",
|
||||
"prebuild": "orval --config orval.config.ts",
|
||||
"build": "vite build",
|
||||
"postbuild": "node tools/check-i18n.mjs",
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Punktfunk",
|
||||
"short_name": "Punktfunk",
|
||||
"description": "Management console for a punktfunk streaming host.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6c5bf3",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getRequestHeader,
|
||||
getRequestURL,
|
||||
sendRedirect,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
useSession,
|
||||
} from "h3";
|
||||
@@ -14,12 +15,30 @@ import {
|
||||
isPublicPath,
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
sessionEpoch,
|
||||
uiPassword,
|
||||
} from "../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
|
||||
// own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
|
||||
// worth having would break third-party plugin pages we don't control. What is safe to assert
|
||||
// unconditionally still closes the cheap holes:
|
||||
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
|
||||
// frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
|
||||
// object-src — no Flash/applet embedding anywhere
|
||||
// base-uri — a stray <base> can't repoint every relative URL on the page
|
||||
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
|
||||
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
|
||||
setResponseHeader(event, "Referrer-Policy", "no-referrer");
|
||||
setResponseHeader(
|
||||
event,
|
||||
"Content-Security-Policy",
|
||||
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
|
||||
);
|
||||
|
||||
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
|
||||
// added with the update-apply route where CSRF ≈ code execution — design
|
||||
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
|
||||
@@ -46,7 +65,10 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const session = await useSession<SessionData>(event, sessionConfig());
|
||||
if (session.data.authenticated) return; // authenticated — let it through
|
||||
// The epoch check is what makes logout mean something: a cookie sealed before the last
|
||||
// revocation unseals fine but no longer matches, so it is refused like any other bad session.
|
||||
if (session.data.authenticated && session.data.epoch === sessionEpoch())
|
||||
return; // authenticated — let it through
|
||||
|
||||
if (pathname.startsWith("/api")) {
|
||||
setResponseStatus(event, 401);
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestIP,
|
||||
readBody,
|
||||
setResponseHeader,
|
||||
useSession,
|
||||
} from "h3";
|
||||
import {
|
||||
peerAddress,
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
sessionEpoch,
|
||||
timingSafeEqual,
|
||||
uiPassword,
|
||||
} from "../../util/auth";
|
||||
@@ -31,9 +32,9 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
// The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit
|
||||
// behind a known proxy, which the packaged console does not). Falls back to a single shared bucket
|
||||
// if the address is somehow unavailable, so the throttle still applies.
|
||||
const ip = getRequestIP(event) ?? "unknown";
|
||||
// behind a known proxy, which the packaged console does not). See `peerAddress`: under the Bun
|
||||
// entry this is the real peer; the shared "unknown" bucket is only a last-resort fallback.
|
||||
const ip = peerAddress(event);
|
||||
|
||||
// Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning.
|
||||
const wait = throttleRetryAfterMs(ip);
|
||||
@@ -53,6 +54,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
recordLoginSuccess(ip);
|
||||
const session = await useSession<SessionData>(event, sessionConfig());
|
||||
await session.update({ authenticated: true });
|
||||
await session.update({ authenticated: true, epoch: sessionEpoch() });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// POST /_auth/logout — clear the session cookie.
|
||||
// POST /_auth/logout — clear the session cookie AND revoke every session issued so far.
|
||||
//
|
||||
// Clearing alone only deletes the browser's copy: the cookie is stateless, so a captured value
|
||||
// stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means
|
||||
// the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out
|
||||
// everywhere" are the same action — which is the safer of the two to make the default.
|
||||
import { defineEventHandler, useSession } from "h3";
|
||||
import { type SessionData, sessionConfig } from "../../util/auth";
|
||||
import {
|
||||
revokeAllSessions,
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
} from "../../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await useSession<SessionData>(event, sessionConfig());
|
||||
await session.clear();
|
||||
revokeAllSessions();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
proxyRequest,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
|
||||
import {
|
||||
isLoopbackUrl,
|
||||
mgmtToken,
|
||||
mgmtUrl,
|
||||
normalizePath,
|
||||
} from "../../util/auth";
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { pathname, search } = getRequestURL(event);
|
||||
@@ -18,7 +23,12 @@ export default defineEventHandler((event) => {
|
||||
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
|
||||
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
|
||||
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
|
||||
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
|
||||
//
|
||||
// Matched against the NORMALIZED path as well as the raw one: `/api//v1/...`, `/api/./v1/...`
|
||||
// and percent-encoded variants all reach the same upstream route, and a denylist that only
|
||||
// knows the canonical spelling is one router-quirk away from leaking the secret.
|
||||
const denied = /^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/i;
|
||||
if (denied.test(pathname) || denied.test(normalizePath(pathname))) {
|
||||
setResponseStatus(event, 403);
|
||||
return {
|
||||
error: "plugin UI credentials are not accessible from the browser",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// GET /api/v1/events — the host's SSE lifecycle stream, proxied with the body left STREAMING.
|
||||
//
|
||||
// Why this route exists at all, when the `/api/**` catch-all already proxies everything:
|
||||
// the generic path cannot stream. h3's `proxyRequest` pumps the upstream body into the node-style
|
||||
// response with `res.write`, and under the deployed Bun entry that response is a `node-mock-http`
|
||||
// object whose writes are accumulated and only turned into a real Response when the handler
|
||||
// returns. Measured: three frames sent one second apart arrive at the browser together, ~3 s late,
|
||||
// when the upstream closes. For an SSE stream that is fatal — it never closes, so nothing ever
|
||||
// arrives, and every event-driven update in the console would silently never fire.
|
||||
//
|
||||
// Returning a WEB `Response` whose body is the upstream's own `ReadableStream` sidesteps the
|
||||
// node-response emulation entirely: h3 hands it back as-is and the Bun entry passes it through.
|
||||
//
|
||||
// Everything else matches the catch-all: session-gated by middleware/auth.ts, mgmt bearer injected
|
||||
// server-side, TLS relaxed only for the loopback hop, 401 → 502.
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestHeader,
|
||||
getRequestURL,
|
||||
} from "h3";
|
||||
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../../util/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = mgmtToken();
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: "management token not configured",
|
||||
});
|
||||
}
|
||||
const base = mgmtUrl();
|
||||
const { search } = getRequestURL(event);
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept: "text/event-stream",
|
||||
// Ask for no compression: a buffering encoder defeats the point of a live stream.
|
||||
"accept-encoding": "identity",
|
||||
};
|
||||
// Forward the SSE resume cursor so a reconnect replays from the host's ring rather than
|
||||
// silently skipping whatever happened while we were away.
|
||||
const lastId = getRequestHeader(event, "last-event-id");
|
||||
if (lastId) headers["last-event-id"] = lastId;
|
||||
|
||||
const init: RequestInit = { method: "GET", headers, redirect: "manual" };
|
||||
if (isLoopbackUrl(base)) {
|
||||
// Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts).
|
||||
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${base}/api/v1/events${search}`, init);
|
||||
} catch (cause) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: "management API unreachable",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (upstream.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage:
|
||||
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
|
||||
});
|
||||
}
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: `management API refused the event stream (${upstream.status})`,
|
||||
});
|
||||
}
|
||||
|
||||
// The upstream body, untouched. `no-transform` + `X-Accel-Buffering: no` tell any intermediary
|
||||
// (and Nitro's own compression) to keep their hands off a live stream.
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// PUT /api/v1/hooks — writing a hook means writing a SHELL COMMAND the host will execute on its own
|
||||
// events, as the host user. That is code execution by any other name, so it joins update/apply and
|
||||
// raw-spec installs behind the console password (util/confirm.ts): a 7-day session cookie must not
|
||||
// be enough to leave a persistent command behind on the machine.
|
||||
//
|
||||
// Wins over the `/api/**` catch-all by h3 route specificity. GET is not gated — reading the current
|
||||
// automation is ordinary console business.
|
||||
import { defineEventHandler, readBody } from "h3";
|
||||
import { confirmPassword } from "../../../util/confirm";
|
||||
import { forwardJson } from "../../../util/forward";
|
||||
|
||||
interface HooksBody {
|
||||
hooks?: unknown[];
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<HooksBody>(event);
|
||||
confirmPassword(event, body?.password);
|
||||
// Rebuild from the one field the host takes, so the password cannot leak upstream.
|
||||
return forwardJson(event, "/api/v1/hooks", "PUT", {
|
||||
hooks: Array.isArray(body?.hooks) ? body.hooks : [],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// POST /api/v1/store/install — wins over the `/api/**` catch-all (h3 route specificity), so the
|
||||
// raw-spec branch can never reach the host without a password.
|
||||
//
|
||||
// Two shapes arrive here:
|
||||
// { source, id } — a curated catalog entry. Forwarded as-is: the operator
|
||||
// already made the trust decision when they added the source.
|
||||
// { spec, accept_unverified: true } — an unreviewed package, no catalog, no pinning. This is
|
||||
// arbitrary code execution on the host, so it is gated on the
|
||||
// console password exactly like update/apply (util/confirm.ts).
|
||||
import { defineEventHandler, readBody } from "h3";
|
||||
import { confirmPassword } from "../../../../util/confirm";
|
||||
import { forwardJson } from "../../../../util/forward";
|
||||
|
||||
interface InstallBody {
|
||||
source?: string;
|
||||
id?: string;
|
||||
spec?: string;
|
||||
accept_unverified?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<InstallBody>(event);
|
||||
const rawSpec = body?.accept_unverified === true;
|
||||
if (rawSpec) confirmPassword(event, body?.password);
|
||||
// The password stops here — rebuild the upstream body from known fields so it cannot leak
|
||||
// through, and so an unexpected extra field can't ride along to the host.
|
||||
const upstream = rawSpec
|
||||
? { spec: String(body?.spec ?? ""), accept_unverified: true }
|
||||
: { source: String(body?.source ?? ""), id: String(body?.id ?? "") };
|
||||
return forwardJson(event, "/api/v1/store/install", "POST", upstream);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// PUT /api/v1/store/sources/{name} — adding or repointing a catalog source is a TRUST-ROOT change:
|
||||
// every future install from that source is admitted on its say-so, and `public_key` is optional, so
|
||||
// a source may be unsigned. That is the boundary worth a password (util/confirm.ts), not each
|
||||
// individual install past it. Wins over the `/api/**` catch-all by h3 route specificity.
|
||||
//
|
||||
// DELETE is deliberately NOT gated — removing a source only ever narrows what the host will trust.
|
||||
import { defineEventHandler, getRouterParam, readBody } from "h3";
|
||||
import { confirmPassword } from "../../../../../util/confirm";
|
||||
import { forwardJson } from "../../../../../util/forward";
|
||||
|
||||
interface SourceBody {
|
||||
url?: string;
|
||||
public_key?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SourceBody>(event);
|
||||
confirmPassword(event, body?.password);
|
||||
const name = getRouterParam(event, "name") ?? "";
|
||||
// Rebuild the body from known fields so the password cannot leak upstream.
|
||||
const upstream: { url: string; public_key?: string } = {
|
||||
url: String(body?.url ?? ""),
|
||||
};
|
||||
const key = body?.public_key?.trim();
|
||||
if (key) upstream.public_key = key;
|
||||
return forwardJson(
|
||||
event,
|
||||
`/api/v1/store/sources/${encodeURIComponent(name)}`,
|
||||
"PUT",
|
||||
upstream,
|
||||
);
|
||||
});
|
||||
@@ -1,89 +1,21 @@
|
||||
// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
|
||||
// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
|
||||
// cookie alone must not be able to update-and-restart the host; the password is verified HERE
|
||||
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
|
||||
// throttle's per-IP budget, so apply can't be used as a password oracle.
|
||||
// POST /api/v1/update/apply — a proxied route with an extra gate: the console password must be
|
||||
// re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session cookie alone
|
||||
// must not be able to update-and-restart the host; the password is verified in `confirmPassword`
|
||||
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login throttle's
|
||||
// per-peer budget, so apply can't be used as a password oracle.
|
||||
//
|
||||
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
|
||||
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
|
||||
// mirrors ../../[...].ts.
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestIP,
|
||||
readBody,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import {
|
||||
isLoopbackUrl,
|
||||
mgmtToken,
|
||||
mgmtUrl,
|
||||
timingSafeEqual,
|
||||
uiPassword,
|
||||
} from "../../../../util/auth";
|
||||
import {
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
throttleRetryAfterMs,
|
||||
} from "../../../../util/loginThrottle";
|
||||
// lives in util/forward.ts and mirrors ../../[...].ts.
|
||||
import { defineEventHandler, readBody } from "h3";
|
||||
import { confirmPassword } from "../../../../util/confirm";
|
||||
import { forwardJson } from "../../../../util/forward";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const expected = uiPassword();
|
||||
if (!expected) {
|
||||
throw createError({ statusCode: 503, statusMessage: "auth not configured" });
|
||||
}
|
||||
const ip = getRequestIP(event) ?? "unknown";
|
||||
const wait = throttleRetryAfterMs(ip);
|
||||
if (wait > 0) {
|
||||
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: "too many attempts — try again shortly",
|
||||
});
|
||||
}
|
||||
|
||||
const body = await readBody<{ password?: string; force?: boolean }>(event);
|
||||
const password = String(body?.password ?? "");
|
||||
if (!timingSafeEqual(password, expected)) {
|
||||
recordLoginFailure(ip);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "password confirmation failed",
|
||||
});
|
||||
}
|
||||
recordLoginSuccess(ip);
|
||||
|
||||
const token = mgmtToken();
|
||||
if (!token) {
|
||||
setResponseStatus(event, 503);
|
||||
return { error: "management token not configured" };
|
||||
}
|
||||
const base = mgmtUrl();
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
// The password stops here — the host only ever sees the force flag.
|
||||
body: JSON.stringify({ force: body?.force === true }),
|
||||
};
|
||||
if (isLoopbackUrl(base)) {
|
||||
// Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
|
||||
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
const upstream = await fetch(`${base}/api/v1/update/apply`, init);
|
||||
if (upstream.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage:
|
||||
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
|
||||
});
|
||||
}
|
||||
setResponseStatus(event, upstream.status);
|
||||
setResponseHeader(event, "content-type", "application/json");
|
||||
return upstream.text();
|
||||
confirmPassword(event, body?.password);
|
||||
// The password stops here — the host only ever sees the force flag.
|
||||
return forwardJson(event, "/api/v1/update/apply", "POST", {
|
||||
force: body?.force === true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,10 +39,12 @@ export default defineEventHandler(async (event) => {
|
||||
delete headers.authorization;
|
||||
headers["x-forwarded-prefix"] = prefix;
|
||||
const method = event.method;
|
||||
const body =
|
||||
method === "GET" || method === "HEAD"
|
||||
? undefined
|
||||
: ((await readRawBody(event, false)) as Uint8Array | undefined);
|
||||
// Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
|
||||
// so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
|
||||
// 405 out of the CONSOLE before the plugin was ever dialed.
|
||||
const body = BODY_METHODS.has(method)
|
||||
? ((await readRawBody(event, false)) as Uint8Array | undefined)
|
||||
: undefined;
|
||||
|
||||
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
|
||||
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
||||
@@ -74,5 +76,54 @@ export default defineEventHandler(async (event) => {
|
||||
setResponseStatus(event, 502);
|
||||
return { error: `plugin "${id}" is not running` };
|
||||
}
|
||||
return sendWebResponse(event, resp);
|
||||
return sendWebResponse(event, sanitize(resp));
|
||||
});
|
||||
|
||||
/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
|
||||
* handed to `readRawBody`. */
|
||||
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/**
|
||||
* Rebuild a plugin's response before it goes out on the console's own origin.
|
||||
*
|
||||
* An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it
|
||||
* returns is asserted for the console itself — and the first version of this dropped four names it
|
||||
* had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the
|
||||
* browser would honour it for this origin and wipe `pf_session`, signing the operator out of the
|
||||
* console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`,
|
||||
* `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us.
|
||||
*
|
||||
* So: name what a plugin page legitimately needs, and drop the rest. Framing headers
|
||||
* (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded
|
||||
* the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are
|
||||
* recomputed.
|
||||
*/
|
||||
const PLUGIN_HEADER_ALLOWLIST = new Set([
|
||||
"content-type",
|
||||
"cache-control",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"expires",
|
||||
"vary",
|
||||
"content-language",
|
||||
"content-disposition",
|
||||
"accept-ranges",
|
||||
"content-range",
|
||||
"location", // its own redirects, within its own prefix
|
||||
"link", // preload hints for its own assets
|
||||
"x-forwarded-prefix",
|
||||
]);
|
||||
|
||||
function sanitize(resp: Response): Response {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of resp.headers) {
|
||||
if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v);
|
||||
}
|
||||
// 204/304 must not carry a body — passing one through throws in the Response constructor.
|
||||
const bodyless = resp.status === 204 || resp.status === 304;
|
||||
return new Response(bodyless ? null : resp.body, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
+120
-3
@@ -1,3 +1,55 @@
|
||||
/**
|
||||
* A revocation marker for issued sessions, PERSISTED across restarts.
|
||||
*
|
||||
* The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only
|
||||
* deletes the BROWSER's copy. A cookie captured beforehand stayed valid for its full 7-day TTL —
|
||||
* "log out" did not log anything out.
|
||||
*
|
||||
* The counter has to survive a restart or it does not do its job: an in-memory `let epoch = 1`
|
||||
* revokes within one process run, then resets to 1 the next time the service starts, and a cookie
|
||||
* captured from that first run is accepted again for the rest of its TTL. (The seal key cannot save
|
||||
* us — it is derived from the stable mgmt token, so pre-restart cookies still unseal fine.) So it
|
||||
* lives in a file next to the host's own config.
|
||||
*
|
||||
* Best-effort by design: if the file cannot be read or written the console still works, it just
|
||||
* falls back to in-memory revocation for this process. Refusing to log anyone out because a state
|
||||
* file is unwritable would be the wrong trade for a LAN console.
|
||||
*/
|
||||
const EPOCH_FILE = (): string =>
|
||||
process.env.PUNKTFUNK_UI_EPOCH_FILE ??
|
||||
join(
|
||||
process.env.PUNKTFUNK_CONFIG_DIR ?? join(homedir(), ".config", "punktfunk"),
|
||||
"web-session-epoch",
|
||||
);
|
||||
|
||||
let epochCache: number | null = null;
|
||||
|
||||
/** The epoch a new session is stamped with, and the one the gate requires. */
|
||||
export function sessionEpoch(): number {
|
||||
if (epochCache !== null) return epochCache;
|
||||
try {
|
||||
const raw = readFileSync(EPOCH_FILE(), "utf8").trim();
|
||||
const n = Number.parseInt(raw, 10);
|
||||
epochCache = Number.isFinite(n) && n > 0 ? n : 1;
|
||||
} catch {
|
||||
epochCache = 1; // no file yet — first run
|
||||
}
|
||||
return epochCache;
|
||||
}
|
||||
|
||||
/** Invalidate every session issued so far (what logging out does). */
|
||||
export function revokeAllSessions(): void {
|
||||
const next = sessionEpoch() + 1;
|
||||
epochCache = next;
|
||||
try {
|
||||
mkdirSync(dirname(EPOCH_FILE()), { recursive: true });
|
||||
writeFileSync(EPOCH_FILE(), String(next), { mode: 0o600 });
|
||||
} catch {
|
||||
// Unwritable state dir: the bump still holds for this process, which is the common case
|
||||
// (log out, walk away). It is weaker than persisted, and better than refusing to log out.
|
||||
}
|
||||
}
|
||||
|
||||
// Shared auth helpers for the Nitro server (the deployed Bun server). Single-user,
|
||||
// shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED
|
||||
// (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts.
|
||||
@@ -8,18 +60,50 @@ import {
|
||||
createHash,
|
||||
timingSafeEqual as nodeTimingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import type { SessionConfig } from "h3";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
getRequestHeader,
|
||||
getRequestIP,
|
||||
type H3Event,
|
||||
type SessionConfig,
|
||||
} from "h3";
|
||||
|
||||
export const SESSION_NAME = "pf_session";
|
||||
|
||||
/** Set by the Bun entry (nitro-entry/bun-https.mjs) to the real socket peer, after deleting any
|
||||
* inbound copy. Keep the name in sync with that file. */
|
||||
const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
|
||||
/**
|
||||
* The requesting peer, as the key for every per-peer budget (currently the login throttle).
|
||||
*
|
||||
* `getRequestIP()` alone does NOT work under the deployed server: Nitro's `localFetch` builds a
|
||||
* synthetic request whose socket carries no `remoteAddress`, so h3 finds nothing and every caller
|
||||
* collapses onto one shared bucket — which turned the "per-IP" login throttle into a lockout any
|
||||
* LAN peer could trigger for everyone. The Bun entry stamps the real peer into PEER_IP_HEADER
|
||||
* (unforgeable: it deletes any client-supplied copy first), so prefer that.
|
||||
*
|
||||
* `getRequestIP` is kept as the fallback for any other ingress (a plain `node`/dev run), and
|
||||
* "unknown" as the last resort — a SHARED bucket, deliberately: an unattributable request must
|
||||
* still be rate-limited, and failing open would make brute force unbounded.
|
||||
*/
|
||||
export function peerAddress(event: H3Event): string {
|
||||
const stamped = getRequestHeader(event, PEER_IP_HEADER)?.trim();
|
||||
if (stamped) return stamped;
|
||||
return getRequestIP(event) ?? "unknown";
|
||||
}
|
||||
|
||||
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
|
||||
export function uiPassword(): string {
|
||||
return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
|
||||
}
|
||||
|
||||
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
|
||||
* HTTPS with the host's self-signed identity cert, so the deployment also sets
|
||||
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
|
||||
* HTTPS with the host's self-signed identity cert, so the proxy relaxes verification for that ONE
|
||||
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
|
||||
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED — see .env.example. */
|
||||
export function mgmtUrl(): string {
|
||||
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
||||
}
|
||||
@@ -118,9 +202,40 @@ export function isPublicPath(pathname: string): boolean {
|
||||
if (pathname.startsWith("/_auth/")) return true;
|
||||
if (pathname.startsWith("/assets/")) return true;
|
||||
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
|
||||
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
|
||||
// visitor cannot already see from the login page (name, colours, the brand mark).
|
||||
if (pathname === "/manifest.webmanifest") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a request path to the shape an upstream router will actually see: percent-decoded,
|
||||
* with empty (`//`) and `.` segments dropped and `..` resolved. Used to test denylists against
|
||||
* something an attacker cannot re-spell — `/api//v1/x`, `/api/./v1/x` and `/api/v1/%78` all reach
|
||||
* the same handler, so matching only the literal path is not a security boundary.
|
||||
*
|
||||
* Decoding is per segment and failure-tolerant: a malformed escape keeps the raw segment rather
|
||||
* than throwing, so a bad path degrades to "does not match the canonical form" instead of a 500.
|
||||
*/
|
||||
export function normalizePath(pathname: string): string {
|
||||
const out: string[] = [];
|
||||
for (const raw of pathname.split("/")) {
|
||||
let seg = raw;
|
||||
try {
|
||||
seg = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Malformed escape — keep the raw segment.
|
||||
}
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
return `/${out.join("/")}`;
|
||||
}
|
||||
|
||||
/** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
|
||||
* sentinel origin and keeps it only if it stays same-origin — rejecting absolute (`https://evil.com`),
|
||||
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
|
||||
@@ -138,4 +253,6 @@ export function safeNextPath(next: string | undefined): string {
|
||||
|
||||
export interface SessionData {
|
||||
authenticated?: boolean;
|
||||
/** The epoch this session was sealed under — see `sessionEpoch`. */
|
||||
epoch?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Password re-confirmation for the routes where an authenticated session is NOT enough.
|
||||
//
|
||||
// The console's session cookie lives for 7 days, so on its own it must not be able to run new code
|
||||
// on the host. Three routes clear that bar and each re-verifies the console password HERE (only the
|
||||
// BFF knows it), strips it, and never forwards it:
|
||||
//
|
||||
// - POST /api/v1/update/apply — update-and-restart the host
|
||||
// - POST /api/v1/store/install — but only for a RAW SPEC (`accept_unverified`), which
|
||||
// runs an unreviewed package
|
||||
// - PUT /api/v1/store/sources/{name} — adds a catalog SOURCE, i.e. a new trust root
|
||||
//
|
||||
// A catalog install from an already-trusted source is deliberately NOT gated: the operator made
|
||||
// that trust decision when they added the source, and re-prompting on every install would train
|
||||
// them to type the password without reading. The gate belongs at the trust boundary, not past it.
|
||||
//
|
||||
// Wrong attempts share the login throttle's per-peer budget, so none of these can be used as a
|
||||
// password oracle, and a lockout covers all of them at once.
|
||||
import { createError, type H3Event, setResponseHeader } from "h3";
|
||||
import { peerAddress, timingSafeEqual, uiPassword } from "./auth";
|
||||
import {
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
throttleRetryAfterMs,
|
||||
} from "./loginThrottle";
|
||||
|
||||
/**
|
||||
* Verify the re-entered console password, or throw the right HTTP error (503 unconfigured,
|
||||
* 429 throttled, 401 wrong). Returns nothing on success — the caller proceeds.
|
||||
*/
|
||||
export function confirmPassword(event: H3Event, password: unknown): void {
|
||||
const expected = uiPassword();
|
||||
if (!expected) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: "auth not configured",
|
||||
});
|
||||
}
|
||||
const ip = peerAddress(event);
|
||||
const wait = throttleRetryAfterMs(ip);
|
||||
if (wait > 0) {
|
||||
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: "too many attempts — try again shortly",
|
||||
});
|
||||
}
|
||||
if (!timingSafeEqual(String(password ?? ""), expected)) {
|
||||
recordLoginFailure(ip);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "password confirmation failed",
|
||||
});
|
||||
}
|
||||
recordLoginSuccess(ip);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// One-shot forward to the management API, for the handful of routes that need their own handler
|
||||
// (a password gate, a rewritten body) instead of the generic `/api/**` passthrough in
|
||||
// routes/api/[...].ts. Everything about how we talk upstream is identical to the passthrough:
|
||||
// server-side bearer injection, loopback-scoped TLS relaxation, and 401 → 502 so a host-token
|
||||
// misconfiguration can't bounce a logged-in user into a redirect loop.
|
||||
import {
|
||||
createError,
|
||||
type H3Event,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
|
||||
|
||||
/** Forward a JSON body to `path` on the management API and relay the upstream response verbatim. */
|
||||
export async function forwardJson(
|
||||
event: H3Event,
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
): Promise<string> {
|
||||
const token = mgmtToken();
|
||||
if (!token) {
|
||||
setResponseStatus(event, 503);
|
||||
setResponseHeader(event, "content-type", "application/json");
|
||||
return JSON.stringify({ error: "management token not configured" });
|
||||
}
|
||||
const base = mgmtUrl();
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
if (isLoopbackUrl(base)) {
|
||||
// Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts).
|
||||
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
// A dead/unstarted host makes `fetch` reject. The generic passthrough answers 502 for that, so
|
||||
// these routes must too — an unreachable upstream is not a console bug, and letting the
|
||||
// rejection escape would surface it as a bare 500 "Server Error".
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${base}${path}`, init);
|
||||
} catch (cause) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: "management API unreachable",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (upstream.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage:
|
||||
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
|
||||
});
|
||||
}
|
||||
setResponseStatus(event, upstream.status);
|
||||
setResponseHeader(event, "content-type", "application/json");
|
||||
return upstream.text();
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// The host's event stream, wired to React Query's cache.
|
||||
//
|
||||
// The host publishes every lifecycle transition on `GET /api/v1/events` as SSE — client
|
||||
// connect/disconnect, session and stream start/end, pairing decisions, display create/release,
|
||||
// library/store/plugin changes, update availability, host start/stop. Nothing consumed it: the
|
||||
// console learned about all of it by asking again on ten separate timers, so a change was up to
|
||||
// 5 s stale, two pages could disagree with each other while you looked at them, and the Library
|
||||
// page — which polls not at all — never noticed a newly installed game until a full reload.
|
||||
//
|
||||
// This subscribes once for the whole app and invalidates exactly the queries an event affects.
|
||||
// It does NOT carry data into the cache: the REST snapshots stay the source of truth, and an event
|
||||
// only says "this is stale now". That keeps the wire format additive-only (a kind we don't know
|
||||
// costs us nothing) and means a missed event degrades to the polling behaviour we already had.
|
||||
//
|
||||
// Transport notes:
|
||||
// - Same-origin, so the sealed session cookie rides along and the BFF injects the mgmt bearer;
|
||||
// no auth work here. `EventSource` reconnects on its own and replays with `Last-Event-ID`,
|
||||
// which h3's proxy forwards, so a dropped connection resumes from the host's ring.
|
||||
// - The host sends a keep-alive comment every 15 s; the Bun entry's idle timeout is set above
|
||||
// that (nitro-entry/bun-https.mjs) so we don't sever our own stream.
|
||||
// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything.
|
||||
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
|
||||
import { getGetDisplayStateQueryKey } from "@/api/gen/display/display";
|
||||
import { getGetStatusQueryKey } from "@/api/gen/host/host";
|
||||
import { getGetLibraryQueryKey } from "@/api/gen/library/library";
|
||||
import { getListNativeClientsQueryKey } from "@/api/gen/native/native";
|
||||
import { getGetPairingStatusQueryKey } from "@/api/gen/pairing/pairing";
|
||||
import { getGetUpdateStatusQueryKey } from "@/api/gen/update/update";
|
||||
import { boostPluginPolling, PLUGINS_KEY } from "@/api/plugins";
|
||||
import { storeKeys } from "@/api/store";
|
||||
|
||||
/** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose.
|
||||
* (The generated key helpers return `readonly` tuples, which is what React Query wants.) */
|
||||
function keysFor(kind: string): readonly (readonly unknown[])[] {
|
||||
const status = [getGetStatusQueryKey()];
|
||||
switch (kind) {
|
||||
// Anything that changes what the host is doing right now moves the dashboard's status.
|
||||
case "client.connected":
|
||||
case "client.disconnected":
|
||||
case "session.started":
|
||||
case "session.ended":
|
||||
case "stream.started":
|
||||
case "stream.stopped":
|
||||
case "game.running":
|
||||
case "game.exited":
|
||||
return status;
|
||||
// A display appearing or going away changes the live list, and its policy card shows
|
||||
// "in effect" values derived from the same state.
|
||||
case "display.created":
|
||||
case "display.released":
|
||||
return [...status, getGetDisplayStateQueryKey()];
|
||||
case "pairing.pending":
|
||||
case "pairing.denied":
|
||||
return [...status, getGetPairingStatusQueryKey()];
|
||||
// A completed pairing also adds a device to whichever plane's list is on screen.
|
||||
case "pairing.completed":
|
||||
return [
|
||||
...status,
|
||||
getGetPairingStatusQueryKey(),
|
||||
getListPairedClientsQueryKey(),
|
||||
getListNativeClientsQueryKey(),
|
||||
];
|
||||
// The base key with no params is a PREFIX of every parameterised library query, and React
|
||||
// Query invalidates by prefix — so this catches the Dashboard's and the Library page's alike.
|
||||
case "library.changed":
|
||||
return [getGetLibraryQueryKey()];
|
||||
case "update.available":
|
||||
case "update.applied":
|
||||
return [getGetUpdateStatusQueryKey()];
|
||||
// A plugin install/uninstall moves the nav, the catalog, and the installed list.
|
||||
case "plugins.changed":
|
||||
case "store.changed":
|
||||
return [
|
||||
PLUGINS_KEY,
|
||||
storeKeys.catalog,
|
||||
storeKeys.installed,
|
||||
storeKeys.runtime,
|
||||
];
|
||||
// The host came back: everything we hold predates it.
|
||||
case "host.started":
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one key's data wrong and refetch it.
|
||||
*
|
||||
* `refetchType: "all"` rather than the default `"active"`: an event says the HOST changed, so every
|
||||
* cached copy is wrong, whether or not a mounted component happens to be observing it right now.
|
||||
* The default only refetches queries with a live observer, which silently did nothing for a page
|
||||
* that had just been re-rendered — the cache stayed marked-stale-but-unfetched and the screen kept
|
||||
* showing the old answer.
|
||||
*/
|
||||
function invalidate(qc: QueryClient, queryKey: readonly unknown[]): void {
|
||||
qc.invalidateQueries({ queryKey, refetchType: "all" });
|
||||
}
|
||||
|
||||
/** Invalidate every query — used on `dropped` (we fell off the ring) and on `host.started`. */
|
||||
function resyncAll(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ refetchType: "all" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The activity log.
|
||||
//
|
||||
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
|
||||
// host been doing?" — a question the console could not answer at all. Nothing else records this:
|
||||
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
|
||||
// narrative. So keep a small in-memory ring alongside the cache work.
|
||||
//
|
||||
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
|
||||
// an audit trail, and a page load starts fresh from whatever the ring replays.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** One thing that happened, as the feed renders it. */
|
||||
export interface ActivityEntry {
|
||||
/** The host's monotonic sequence number — stable, and a good React key. */
|
||||
seq: number;
|
||||
/** Unix ms, from the host's clock (never the browser's). */
|
||||
ts_ms: number;
|
||||
kind: string;
|
||||
/** The event payload, shape depending on `kind` (see the EventKind schema). */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ACTIVITY_MAX = 200;
|
||||
let activity: ActivityEntry[] = [];
|
||||
const activityListeners = new Set<() => void>();
|
||||
|
||||
function pushActivity(entry: ActivityEntry): void {
|
||||
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
|
||||
if (activity.some((e) => e.seq === entry.seq)) return;
|
||||
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
|
||||
for (const l of activityListeners) l();
|
||||
}
|
||||
|
||||
/** The activity tail, newest first. Re-renders as frames arrive. */
|
||||
export function useActivity(): ActivityEntry[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
activityListeners.add(cb);
|
||||
return () => activityListeners.delete(cb);
|
||||
},
|
||||
() => activity,
|
||||
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
|
||||
() => EMPTY_ACTIVITY,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ACTIVITY: ActivityEntry[] = [];
|
||||
|
||||
/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */
|
||||
const KINDS = [
|
||||
"client.connected",
|
||||
"client.disconnected",
|
||||
"session.started",
|
||||
"session.ended",
|
||||
"stream.started",
|
||||
"stream.stopped",
|
||||
"game.running",
|
||||
"game.exited",
|
||||
"pairing.pending",
|
||||
"pairing.completed",
|
||||
"pairing.denied",
|
||||
"display.created",
|
||||
"display.released",
|
||||
"library.changed",
|
||||
"update.available",
|
||||
"update.applied",
|
||||
"plugins.changed",
|
||||
"store.changed",
|
||||
"host.started",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The connection is a module-level singleton, refcounted, NOT a per-component resource.
|
||||
//
|
||||
// It has to be. The subscription is app-lifetime, but the component that asks for it is not:
|
||||
// during hydration TanStack Start mounts the app shell and discards it again ~15 ms later
|
||||
// (measured), which ran an effect cleanup with no matching re-mount. Tied to that effect, the
|
||||
// stream opened, closed, and never came back — the console looked subscribed and received nothing.
|
||||
//
|
||||
// So: `open()` hands out a reference and only the LAST release closes the socket, after a short
|
||||
// grace period, so a remount inside that window re-attaches to the live stream instead of
|
||||
// reconnecting. `EventSource` handles reconnection itself and replays with `Last-Event-ID`, which
|
||||
// the SSE route forwards.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
let source: EventSource | null = null;
|
||||
let refs = 0;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** The client to invalidate against — one per page load, re-pointed if React hands us a new one. */
|
||||
let client: QueryClient | null = null;
|
||||
|
||||
/** How long the stream survives with no subscribers, so a hydration blip doesn't reconnect. */
|
||||
const CLOSE_GRACE_MS = 10_000;
|
||||
|
||||
function attach(): void {
|
||||
if (source) return;
|
||||
source = new EventSource("/api/v1/events");
|
||||
for (const kind of KINDS) {
|
||||
source.addEventListener(kind, (ev) => {
|
||||
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
|
||||
recordActivity(kind, ev);
|
||||
if (!client) return;
|
||||
// The installed set changed — but the runner is probably still restarting, so keep
|
||||
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
|
||||
if (kind === "plugins.changed" || kind === "store.changed")
|
||||
boostPluginPolling();
|
||||
for (const key of keysFor(kind)) invalidate(client, key);
|
||||
// `host.started` names no keys — the host is NEW, so everything we hold predates it.
|
||||
if (kind === "host.started") resyncAll(client);
|
||||
});
|
||||
}
|
||||
// We fell off the host's ring — every snapshot we hold may be wrong.
|
||||
source.addEventListener("dropped", () => {
|
||||
if (client) resyncAll(client);
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
|
||||
function recordActivity(kind: string, ev: Event): void {
|
||||
const raw = (ev as MessageEvent<string>).data;
|
||||
if (typeof raw !== "string") return;
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
|
||||
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
|
||||
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
|
||||
pushActivity({ seq, ts_ms: ts, kind, data });
|
||||
} catch {
|
||||
// A frame we cannot parse is not worth breaking the stream over.
|
||||
}
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
refs -= 1;
|
||||
if (refs > 0) return;
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
closeTimer = null;
|
||||
if (refs > 0) return; // someone re-subscribed inside the grace window
|
||||
source?.close();
|
||||
source = null;
|
||||
}, CLOSE_GRACE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the host's event stream. Safe to call from more than one component and safe on the
|
||||
* server (`EventSource` is browser-only, so this is a no-op during SSR).
|
||||
*/
|
||||
export function useHostEvents(): void {
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof EventSource === "undefined")
|
||||
return;
|
||||
client = qc;
|
||||
refs += 1;
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
attach();
|
||||
return release;
|
||||
}, [qc]);
|
||||
}
|
||||
+18
-2
@@ -39,15 +39,31 @@ export async function apiFetch<T>(
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** On lost session, send the user to the login screen, remembering where they were. */
|
||||
/**
|
||||
* On lost session, send the user to the login screen, remembering where they were.
|
||||
*
|
||||
* Deferred by a beat rather than navigating inline. This runs inside whichever call noticed the
|
||||
* 401 — very often a background poll the user never asked for — and a synchronous
|
||||
* `location.href =` there tears the page down mid-render, taking any unsaved editing state with it
|
||||
* (the Displays page models exactly such a draft). Letting the current task finish first means the
|
||||
* caller's own error handling still runs, and a `beforeunload` guard can still speak up.
|
||||
*
|
||||
* Guarded so a burst of parallel 401s (every card on a page polling at once) schedules one
|
||||
* navigation, not one per request.
|
||||
*/
|
||||
let redirecting = false;
|
||||
function redirectToLogin(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.pathname === "/login") return;
|
||||
if (redirecting) return;
|
||||
redirecting = true;
|
||||
// Keep the full path (query + hash too), so re-login returns to the exact view.
|
||||
const next = encodeURIComponent(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
);
|
||||
window.location.href = `/login?next=${next}`;
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?next=${next}`;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Automation (event hooks). Read uses the generated query; the WRITE is hand-rolled because it
|
||||
// carries the console password, which the BFF verifies and strips
|
||||
// (server/routes/api/v1/hooks.put.ts) — a hook is a shell command the host will run on its own
|
||||
// events, so a session cookie alone must not be able to install one.
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/api/fetcher";
|
||||
import { getGetHooksQueryKey } from "@/api/gen/hooks/hooks";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
|
||||
/** The whole automation config is written at once — the host has no per-hook route. */
|
||||
export function useSaveHooks() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
hooks,
|
||||
password,
|
||||
}: {
|
||||
hooks: HookEntry[];
|
||||
password: string;
|
||||
}) =>
|
||||
apiFetch<void>("/api/v1/hooks", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hooks, password }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: getGetHooksQueryKey() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A one-line description of what a hook does, for the list row. */
|
||||
export function hookAction(h: HookEntry): string {
|
||||
if (h.run) return h.run;
|
||||
if (h.webhook) return h.webhook;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Human summary of a hook's filter, or "" when it matches everything. */
|
||||
export function hookFilterSummary(h: HookEntry): string {
|
||||
const f = h.filter;
|
||||
if (!f) return "";
|
||||
return [
|
||||
f.client && `client=${f.client}`,
|
||||
f.app && `app=${f.app}`,
|
||||
f.plane && `plane=${f.plane}`,
|
||||
f.fingerprint && `fp=${f.fingerprint.slice(0, 12)}…`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
+42
-5
@@ -46,16 +46,53 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
clapperboard: Clapperboard,
|
||||
};
|
||||
|
||||
/** Resolve a registered icon name to a component (Puzzle fallback). */
|
||||
export const pluginIcon = (name?: string): LucideIcon =>
|
||||
(name ? ICONS[name] : undefined) ?? Puzzle;
|
||||
/**
|
||||
* Resolve a registered icon name to a component (Puzzle fallback).
|
||||
*
|
||||
* `name` comes from a plugin's own registration, so it is untrusted input to a lookup on a plain
|
||||
* object — and a plain object inherits from Object.prototype. `ICONS["constructor"]` is `Object`,
|
||||
* which is truthy, so a `?? Puzzle` fallback never fires and React is handed `Object` as a
|
||||
* component: it throws out of render, and because this runs inside the AppShell nav that takes
|
||||
* down every page of the console. `Object.hasOwn` keeps the lookup to keys we actually declared.
|
||||
*/
|
||||
export const pluginIcon = (name?: string): LucideIcon => {
|
||||
if (!name || !Object.hasOwn(ICONS, name)) return Puzzle;
|
||||
return ICONS[name] ?? Puzzle;
|
||||
};
|
||||
|
||||
/** The query key for the plugin directory — the nav is built from it. */
|
||||
export const PLUGINS_KEY = ["plugins"] as const;
|
||||
|
||||
const IDLE_POLL_MS = 30_000;
|
||||
const BOOST_POLL_MS = 2_000;
|
||||
/** How long to keep polling fast after something changed the installed set. */
|
||||
const BOOST_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Until this timestamp, poll the directory fast.
|
||||
*
|
||||
* A finished install is NOT the moment the plugin appears: the host restarts the scripting runner
|
||||
* afterwards, and the plugin only registers its UI once that comes back — several seconds later,
|
||||
* and after any one-shot invalidation has already run and found the old list. So the nav sat
|
||||
* unchanged until the 30 s idle poll happened to land, which in practice meant "until I reloaded".
|
||||
*
|
||||
* Module-level rather than component state because the two things that need to trigger it (a store
|
||||
* job settling, a `plugins.changed`/`store.changed` event) both live outside the nav.
|
||||
*/
|
||||
let boostUntil = 0;
|
||||
|
||||
/** Poll the plugin directory fast for a while — call after anything that changes what's installed. */
|
||||
export function boostPluginPolling(): void {
|
||||
boostUntil = Date.now() + BOOST_MS;
|
||||
}
|
||||
|
||||
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
|
||||
export function usePlugins() {
|
||||
return useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryKey: PLUGINS_KEY,
|
||||
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: () =>
|
||||
Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
+68
-4
@@ -11,6 +11,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/api/fetcher";
|
||||
import { boostPluginPolling } from "@/api/plugins";
|
||||
|
||||
/**
|
||||
* How much a plugin's provenance is worth, from most to least trustworthy:
|
||||
@@ -80,7 +81,9 @@ export interface StoreCatalog {
|
||||
|
||||
export interface InstalledPlugin {
|
||||
pkg: string;
|
||||
version: string;
|
||||
/** Nullable in the contract (`InstalledView.version`) — a CLI-installed plugin may carry no
|
||||
* recorded version. Typed required here, the Installed tab rendered the literal "vundefined". */
|
||||
version?: string | null;
|
||||
tier: StoreTier;
|
||||
source?: string;
|
||||
entry_id?: string;
|
||||
@@ -123,14 +126,24 @@ export interface JobAccepted {
|
||||
job: string;
|
||||
}
|
||||
|
||||
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
|
||||
/**
|
||||
* Install a curated catalog entry, or — deliberately awkward — a raw package spec.
|
||||
*
|
||||
* The raw-spec branch carries the console `password`: it runs unreviewed code, so the BFF
|
||||
* re-confirms it (server/routes/api/v1/store/install.post.ts) and strips it before the host ever
|
||||
* sees the request. A catalog install needs no password — that trust decision was made when the
|
||||
* source was added.
|
||||
*/
|
||||
export type InstallBody =
|
||||
| { source: string; id: string }
|
||||
| { spec: string; accept_unverified: true };
|
||||
| { spec: string; accept_unverified: true; password: string };
|
||||
|
||||
/** Adding or repointing a source is a trust-root change, so it carries the console password too
|
||||
* (stripped at the BFF — server/routes/api/v1/store/sources/[name].put.ts). */
|
||||
export interface SourceBody {
|
||||
url: string;
|
||||
public_key?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/store";
|
||||
@@ -156,6 +169,9 @@ const json = (method: string, body: unknown): RequestInit => ({
|
||||
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
|
||||
*/
|
||||
export function invalidateStore(qc: QueryClient): Promise<void> {
|
||||
// The runner restarts AFTER the job reports done, so the plugin registers its UI a few seconds
|
||||
// from now — this invalidation would otherwise refetch the pre-install list and stop looking.
|
||||
boostPluginPolling();
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.installed }),
|
||||
@@ -199,14 +215,62 @@ export function useStoreRuntime() {
|
||||
/**
|
||||
* A single install/uninstall job, polled once a second while it runs and left alone once it
|
||||
* settles. Pass `null` to park the query (no job in flight).
|
||||
*
|
||||
* The interval keys off "not finished yet" rather than off `state === "running"`. `data` is
|
||||
* undefined in two live cases — the first poll has not landed, and the first poll FAILED — and
|
||||
* treating those as "stop polling" wedged the card: an install whose very first poll lost the race
|
||||
* with a busy host never polled again and the operator saw nothing at all, while an install that
|
||||
* restarts the runner (every successful one does) could drop a poll mid-flight.
|
||||
*
|
||||
* The failure count bounds it: jobs live in host memory, so a host restart makes the id 404 for
|
||||
* good, and something has to stop asking.
|
||||
*/
|
||||
const JOB_POLL_MS = 1_000;
|
||||
const JOB_MAX_FAILURES = 15;
|
||||
|
||||
/**
|
||||
* The host's recent jobs, used to RE-ATTACH after a reload.
|
||||
*
|
||||
* The in-flight job id lived only in component state, so reloading the page (or opening the console
|
||||
* on another device) lost all trace of a running install while the Install buttons stayed armed —
|
||||
* and the host takes one job at a time, so the next click just bounced off a 409. The host keeps
|
||||
* the list; ask it rather than remembering.
|
||||
*/
|
||||
export function useStoreJobs() {
|
||||
return useQuery({
|
||||
queryKey: [...storeKeys.all, "jobs"] as const,
|
||||
queryFn: () => apiFetch<StoreJob[]>(`${BASE}/jobs`),
|
||||
// Only needed to find an orphaned job on mount; the job query itself does the live polling.
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** The newest job that is still running, if any — what a fresh page should re-attach to. */
|
||||
export function runningJob(jobs: StoreJob[] | undefined): StoreJob | undefined {
|
||||
if (!jobs) return undefined;
|
||||
// The list is oldest-first, so scan from the end for the most recent live one.
|
||||
for (let i = jobs.length - 1; i >= 0; i--) {
|
||||
const j = jobs[i];
|
||||
if (j?.state === "running") return j;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useStoreJob(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.job(id ?? ""),
|
||||
queryFn: () =>
|
||||
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
|
||||
enabled: id !== null,
|
||||
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
|
||||
refetchInterval: (q) => {
|
||||
const state = q.state.data?.state;
|
||||
if (state === "done" || state === "failed") return false;
|
||||
if (q.state.fetchFailureCount > JOB_MAX_FAILURES) return false;
|
||||
return JOB_POLL_MS;
|
||||
},
|
||||
// A job that vanished with its host is gone for good; a transient blip is not. Retry a few
|
||||
// times per poll so a runner restart doesn't surface as an error card.
|
||||
retry: 3,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useHostEvents } from "@/api/events";
|
||||
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Wordmark } from "@/components/wordmark";
|
||||
@@ -30,6 +32,7 @@ const NAV = [
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/automation", icon: Workflow, label: () => m.nav_automation() },
|
||||
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
@@ -47,6 +50,10 @@ const MOBILE_OVERFLOW = NAV.slice(4);
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
// Read the locale so the whole shell re-renders on a language switch.
|
||||
useLocale();
|
||||
// One subscription to the host's event stream for the whole console — it invalidates the queries
|
||||
// each event affects, so pages update on the transition instead of on their own timer. The
|
||||
// polling intervals stay as a floor in case the stream is unavailable.
|
||||
useHostEvents();
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar (≥ sm). Sticky at viewport height: the page (body) scrolls with
|
||||
@@ -136,32 +143,57 @@ function PluginNavSection() {
|
||||
const plugins = uiPlugins(data);
|
||||
if (plugins.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-1">
|
||||
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
// Its own animation container, with the same variants + stagger as the main nav above. These
|
||||
// were plain links: the group sits OUTSIDE that `motion.nav`, so it inherited neither the
|
||||
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
|
||||
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
|
||||
// the animation earns its keep.
|
||||
<motion.div
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="mt-6 flex flex-col gap-1"
|
||||
>
|
||||
<motion.p
|
||||
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
|
||||
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
|
||||
>
|
||||
{m.nav_plugins()}
|
||||
</p>
|
||||
</motion.p>
|
||||
{plugins.map((p) => {
|
||||
const Icon = pluginIcon(p.ui?.icon);
|
||||
return (
|
||||
<Link
|
||||
// The motion wrapper is a DIV around the link, not `motion(Link)`: wrapping Link
|
||||
// erases TanStack's typed `params`, and these entries need `$pluginId`.
|
||||
<motion.div
|
||||
key={p.id}
|
||||
to="/plugins/$pluginId/$"
|
||||
params={{ pluginId: p.id, _splat: "" }}
|
||||
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className: "bg-primary/15 text-foreground font-medium",
|
||||
variants={{
|
||||
from: { opacity: 0, x: -20 },
|
||||
enter: { opacity: 1, x: 0 },
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
|
||||
/>
|
||||
<Icon className="relative size-4" />
|
||||
<span className="relative truncate">{p.title}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/plugins/$pluginId/$"
|
||||
params={{ pluginId: p.id, _splat: "" }}
|
||||
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className: "bg-primary/15 text-foreground font-medium",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
|
||||
/>
|
||||
<Icon className="relative size-4" />
|
||||
<span className="relative truncate">{p.title}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +221,7 @@ function MobileNav() {
|
||||
{moreOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
aria-label={m.nav_close_menu()}
|
||||
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
/>
|
||||
@@ -270,7 +302,7 @@ function LanguageSwitcher() {
|
||||
const current = useLocale();
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
|
||||
<div className="flex gap-1" role="group" aria-label="Language">
|
||||
<div className="flex gap-1" role="group" aria-label={m.settings_language()}>
|
||||
{locales.map((l: Locale) => (
|
||||
<button
|
||||
key={l}
|
||||
|
||||
@@ -32,7 +32,13 @@ export function QueryState({
|
||||
if (error) {
|
||||
const unauthorized = error instanceof ApiError && error.status === 401;
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
// `role="alert"` so the failure is announced. The loading branch above already has
|
||||
// role="status"; without this, a query that resolved into an error swapped one silent
|
||||
// region for another and a screen-reader user was told only that loading had stopped.
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"
|
||||
>
|
||||
<p className="font-medium text-destructive">
|
||||
{unauthorized ? m.common_unauthorized() : m.common_error()}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
|
||||
// recreated from divs and animated as if orbiting on a path whose long axis points
|
||||
@@ -76,7 +77,7 @@ export function Spinner({
|
||||
<div
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
aria-label={m.common_loading()}
|
||||
className={cn("relative inline-block size-6 isolate", className)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
|
||||
/**
|
||||
* The server's own `{ error }` message from a thrown `ApiError` (its `.data` body), for inline
|
||||
* display — falling back to the HTTP status text, then to whatever was thrown.
|
||||
*
|
||||
* The host writes genuinely useful refusals ("entry is owned by provider `x` — update it through
|
||||
* its reconcile"), and showing a generic "something went wrong" in their place throws away the one
|
||||
* piece of information that tells the operator what to do next.
|
||||
*
|
||||
* Lives here rather than in a section because several of them need it; it started life private to
|
||||
* the display card.
|
||||
*/
|
||||
export function apiErrorMessage(err: unknown): string | undefined {
|
||||
if (err instanceof ApiError) {
|
||||
const data = err.data as { error?: string } | undefined;
|
||||
return data?.error ?? err.message;
|
||||
}
|
||||
return err ? String(err) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getLocale } from "@/paraglide/runtime";
|
||||
|
||||
/**
|
||||
* Date/time and number formatting that follows the CONSOLE's locale, not the browser's.
|
||||
*
|
||||
* A bare `toLocaleString()` uses `navigator.language`, so a console switched to German still
|
||||
* rendered US-style timestamps (and vice versa) — the app said one thing and its dates another.
|
||||
* `getLocale()` is Paraglide's resolved locale, which is what every string on screen uses.
|
||||
*
|
||||
* `Intl` formatters are expensive to construct and these run per table row, so they are cached
|
||||
* per locale.
|
||||
*/
|
||||
const dateTimeCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function dateTimeFor(locale: string): Intl.DateTimeFormat {
|
||||
let f = dateTimeCache.get(locale);
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
dateTimeCache.set(locale, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Unix MILLISECONDS → a locale date-time, or an em dash for "never". */
|
||||
export function fmtDateTime(unixMs: number | undefined | null): string {
|
||||
if (!unixMs) return "—";
|
||||
return dateTimeFor(getLocale()).format(new Date(unixMs));
|
||||
}
|
||||
|
||||
/** Unix SECONDS → a locale date-time (the store's `fetched_at` convention). */
|
||||
export function fmtDateTimeSecs(unixSecs: number | undefined | null): string {
|
||||
if (!unixSecs) return "—";
|
||||
return fmtDateTime(unixSecs * 1000);
|
||||
}
|
||||
|
||||
/** A number with the console locale's separators — never a hand-rolled `toFixed`. */
|
||||
export function fmtNumber(value: number, digits = 0): string {
|
||||
return new Intl.NumberFormat(getLocale(), {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
+27
-2
@@ -3,8 +3,8 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { ApiError } from "./api/fetcher";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export function getRouter() {
|
||||
const queryClient = new QueryClient({
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 2_000,
|
||||
@@ -21,6 +21,31 @@ export function getRouter() {
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's ONE QueryClient.
|
||||
*
|
||||
* `getRouter()` can run more than once per page load (hydration discards and rebuilds the tree),
|
||||
* and a fresh client each time means a fresh, empty cache that nothing else holds a reference to.
|
||||
* That is how the event stream ended up invalidating a cache no component was reading: the
|
||||
* subscription captured the client from the first router, the live pages read the second one, and
|
||||
* every invalidation went to the dead one. One client per browser session fixes that and keeps the
|
||||
* cache across a router rebuild.
|
||||
*
|
||||
* Deliberately browser-only: on the SERVER every request must get its OWN client, or one visitor's
|
||||
* data would be served from another's cache.
|
||||
*/
|
||||
let browserQueryClient: QueryClient | undefined;
|
||||
|
||||
export function getRouter() {
|
||||
let queryClient: QueryClient;
|
||||
if (typeof window === "undefined") {
|
||||
queryClient = createQueryClient();
|
||||
} else {
|
||||
if (!browserQueryClient) browserQueryClient = createQueryClient();
|
||||
queryClient = browserQueryClient;
|
||||
}
|
||||
|
||||
return createTanStackRouter({
|
||||
routeTree,
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import "@fontsource-variable/geist";
|
||||
import { Toaster } from "@unom/ui/toast";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { adoptStoredLocale } from "@/lib/i18n";
|
||||
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
|
||||
import appCss from "@/styles.css?url";
|
||||
|
||||
export interface RouterContext {
|
||||
@@ -25,11 +26,19 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "color-scheme", content: "dark light" },
|
||||
{ name: "theme-color", content: "#6c5bf3" },
|
||||
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-title", content: "Punktfunk" },
|
||||
{ title: "Punktfunk" },
|
||||
],
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
|
||||
// Installable on a phone — this console is used from a couch as often as from a desk,
|
||||
// and a home-screen launcher beats retyping a LAN IP. Standalone display, no service
|
||||
// worker: an offline shell for a console whose every screen is live host state would
|
||||
// only ever show stale numbers convincingly.
|
||||
{ rel: "manifest", href: "/manifest.webmanifest" },
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
@@ -41,23 +50,32 @@ function RootComponent() {
|
||||
useEffect(() => {
|
||||
adoptStoredLocale();
|
||||
}, []);
|
||||
// `lang` must track the locale the page is actually rendered in — it is what tells a screen
|
||||
// reader which pronunciation to use, and it was pinned to "en" while the app switched to German
|
||||
// underneath it. `adoptStoredLocale` also sets it on the live document; this keeps SSR honest.
|
||||
const locale = useLocale();
|
||||
// The login screen renders bare (no sidebar); everything else gets the app shell.
|
||||
const isLogin = useRouterState({
|
||||
select: (s) => s.location.pathname === "/login",
|
||||
});
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
{isLogin ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<AppShell>
|
||||
{/* Motion defaults to `reducedMotion: "never"`, so every card, nav item and button
|
||||
animated at full strength even for someone whose OS asks for less. "user" honours
|
||||
the OS setting. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
{isLogin ? (
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)}
|
||||
) : (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)}
|
||||
</MotionConfig>
|
||||
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
|
||||
<Toaster />
|
||||
<Scripts />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionAutomation } from "@/sections/Automation";
|
||||
|
||||
export const Route = createFileRoute("/automation")({
|
||||
component: SectionAutomation,
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
|
||||
* Same vocabulary as the SSE `?kinds=` filter, so the two stay learnable together. */
|
||||
export const EVENT_KINDS = [
|
||||
"client.*",
|
||||
"client.connected",
|
||||
"client.disconnected",
|
||||
"session.*",
|
||||
"session.started",
|
||||
"session.ended",
|
||||
"stream.*",
|
||||
"stream.started",
|
||||
"stream.stopped",
|
||||
"game.*",
|
||||
"game.running",
|
||||
"game.exited",
|
||||
"pairing.*",
|
||||
"pairing.pending",
|
||||
"pairing.completed",
|
||||
"pairing.denied",
|
||||
"display.*",
|
||||
"display.created",
|
||||
"display.released",
|
||||
"library.changed",
|
||||
"update.available",
|
||||
"update.applied",
|
||||
"host.started",
|
||||
"host.stopping",
|
||||
] as const;
|
||||
|
||||
const EMPTY: HookEntry = { on: "session.started", run: "" };
|
||||
|
||||
/**
|
||||
* Add or edit one hook.
|
||||
*
|
||||
* A hook is either a shell command or a webhook — never both in this form, because "run this AND
|
||||
* post that" is two hooks and pretending otherwise makes the failure modes impossible to reason
|
||||
* about. The action kind is therefore a choice, not two optional fields.
|
||||
*/
|
||||
export const HookForm: FC<{
|
||||
/** The hook being edited, `EMPTY`-seeded for a new one, or null when closed. */
|
||||
value: HookEntry | null;
|
||||
onCancel: () => void;
|
||||
onSave: (hook: HookEntry) => void;
|
||||
}> = ({ value, onCancel, onSave }) => {
|
||||
const [draft, setDraft] = useState<HookEntry>(EMPTY);
|
||||
const [kind, setKind] = useState<"run" | "webhook">("run");
|
||||
const [filtered, setFiltered] = useState(false);
|
||||
|
||||
// Re-seed whenever a different hook is opened (the dialog stays mounted between edits).
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
setDraft(value);
|
||||
setKind(value.webhook ? "webhook" : "run");
|
||||
setFiltered(!!value.filter);
|
||||
}, [value]);
|
||||
|
||||
const set = (patch: Partial<HookEntry>) =>
|
||||
setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const action = kind === "run" ? (draft.run ?? "") : (draft.webhook ?? "");
|
||||
const ready = draft.on.trim().length > 0 && action.trim().length > 0;
|
||||
|
||||
const commit = () => {
|
||||
// Emit exactly one action field, and drop an unticked filter entirely — leaving `{}` behind
|
||||
// would read as "filter on nothing" to anyone reading the config file later.
|
||||
const out: HookEntry = {
|
||||
on: draft.on.trim(),
|
||||
...(kind === "run"
|
||||
? { run: action.trim(), webhook: null }
|
||||
: { webhook: action.trim(), run: null }),
|
||||
...(filtered && draft.filter ? { filter: draft.filter } : {}),
|
||||
...(draft.debounce_ms ? { debounce_ms: draft.debounce_ms } : {}),
|
||||
...(draft.timeout_s ? { timeout_s: draft.timeout_s } : {}),
|
||||
...(kind === "webhook" && draft.hmac_secret_file
|
||||
? { hmac_secret_file: draft.hmac_secret_file }
|
||||
: {}),
|
||||
};
|
||||
onSave(out);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={value !== null} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent className="max-h-[85vh] max-w-xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{m.automation_hook_title()}</DialogTitle>
|
||||
<DialogDescription>{m.automation_hook_help()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
|
||||
<select
|
||||
id="hook-on"
|
||||
value={draft.on}
|
||||
onChange={(e) => set({ on: e.target.value })}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_on_help()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{m.automation_field_action()}
|
||||
</legend>
|
||||
<div className="flex gap-2">
|
||||
{(["run", "webhook"] as const).map((k) => (
|
||||
<Button
|
||||
key={k}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={kind === k ? "default" : "outline"}
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => setKind(k)}
|
||||
>
|
||||
{k === "run"
|
||||
? m.automation_action_run()
|
||||
: m.automation_action_webhook()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="hook-action"
|
||||
aria-label={m.automation_field_action()}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={action}
|
||||
placeholder={
|
||||
kind === "run" ? "/usr/local/bin/on-stream.sh" : "https://…"
|
||||
}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
kind === "run"
|
||||
? { run: e.target.value }
|
||||
: { webhook: e.target.value },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind === "run"
|
||||
? m.automation_action_run_help()
|
||||
: m.automation_action_webhook_help()}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{kind === "webhook" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-hmac">{m.automation_field_hmac()}</Label>
|
||||
<Input
|
||||
id="hook-hmac"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.hmac_secret_file ?? ""}
|
||||
onChange={(e) => set({ hmac_secret_file: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_hmac_help()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Label className="flex items-start gap-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={filtered}
|
||||
onCheckedChange={(n) => setFiltered(n === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{m.automation_field_filter()}</span>
|
||||
</Label>
|
||||
|
||||
{filtered && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-client">
|
||||
{m.automation_filter_client()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-client"
|
||||
value={draft.filter?.client ?? ""}
|
||||
onChange={(e) =>
|
||||
set({ filter: { ...draft.filter, client: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-app">{m.automation_filter_app()}</Label>
|
||||
<Input
|
||||
id="hook-app"
|
||||
value={draft.filter?.app ?? ""}
|
||||
onChange={(e) =>
|
||||
set({ filter: { ...draft.filter, app: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-debounce">
|
||||
{m.automation_field_debounce()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-debounce"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.debounce_ms ?? 0}
|
||||
onChange={(e) =>
|
||||
set({ debounce_ms: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{kind === "run" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hook-timeout">
|
||||
{m.automation_field_timeout()}
|
||||
</Label>
|
||||
<Input
|
||||
id="hook-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={draft.timeout_s ?? 30}
|
||||
onChange={(e) =>
|
||||
set({ timeout_s: Number(e.target.value) || 30 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button disabled={!ready} onClick={commit}>
|
||||
{m.automation_hook_save()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Pencil, Plus, Terminal, Trash2, Webhook } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import { useGetHooks } from "@/api/gen/hooks/hooks";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { HookForm } from "./HookForm";
|
||||
|
||||
/**
|
||||
* **Automation** — the operator's event hooks (`GET/PUT /api/v1/hooks`).
|
||||
*
|
||||
* The host has run these since the API existed and the console never showed them: the only way to
|
||||
* see or change what your machine does when a stream starts was to edit the config file by hand.
|
||||
*
|
||||
* The whole list is written in one PUT (the host has no per-hook route), so this edits a local copy
|
||||
* and saves explicitly — no auto-save. That is deliberate for a screen whose contents are shell
|
||||
* commands: a half-typed command should never reach the host because a poll landed.
|
||||
*/
|
||||
export const SectionAutomation: FC = () => {
|
||||
useLocale();
|
||||
const query = useGetHooks();
|
||||
const save = useSaveHooks();
|
||||
|
||||
const [hooks, setHooks] = useState<HookEntry[] | null>(null);
|
||||
const [editing, setEditing] = useState<{
|
||||
index: number;
|
||||
hook: HookEntry;
|
||||
} | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [wrongPassword, setWrongPassword] = useState(false);
|
||||
|
||||
// Seed once. Unlike the display card there is no re-seed-when-clean dance: nothing else in the
|
||||
// console writes hooks, so the server value cannot move underneath an edit.
|
||||
const server = query.data?.hooks;
|
||||
useEffect(() => {
|
||||
if (hooks === null && server) setHooks(server);
|
||||
}, [server, hooks]);
|
||||
|
||||
const list = hooks ?? [];
|
||||
const dirty =
|
||||
hooks !== null && JSON.stringify(hooks) !== JSON.stringify(server ?? []);
|
||||
|
||||
const upsert = (hook: HookEntry) => {
|
||||
if (!editing) return;
|
||||
setHooks((prev) => {
|
||||
const next = [...(prev ?? [])];
|
||||
if (editing.index < 0) next.push(hook);
|
||||
else next[editing.index] = hook;
|
||||
return next;
|
||||
});
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
if (!confirm(m.automation_delete_confirm())) return;
|
||||
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
setWrongPassword(false);
|
||||
try {
|
||||
await save.mutateAsync({ hooks: list, password });
|
||||
setConfirming(false);
|
||||
setPassword("");
|
||||
toast.success(m.automation_saved());
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
toast.error(m.automation_save_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.automation_title()}</h1>
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.automation_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>{m.automation_hooks_title()}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
index: -1,
|
||||
hook: { on: "session.started", run: "" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{m.automation_add()}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<QueryState
|
||||
isLoading={query.isLoading}
|
||||
error={query.error}
|
||||
refetch={query.refetch}
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.automation_empty()}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((h, i) => (
|
||||
<li
|
||||
// The list is operator-ordered and has no ids; the index IS the identity
|
||||
// here, and rows only move when the operator moves them.
|
||||
key={`${h.on}:${hookAction(h)}:${i}`}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{h.webhook ? (
|
||||
<Webhook className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Terminal className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{h.on}</Badge>
|
||||
{hookFilterSummary(h) && (
|
||||
<Badge variant="outline">
|
||||
{hookFilterSummary(h)}
|
||||
</Badge>
|
||||
)}
|
||||
{!!h.debounce_ms && (
|
||||
<Badge variant="outline">
|
||||
{m.automation_debounce_badge({
|
||||
ms: h.debounce_ms,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{hookAction(h)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.automation_edit()}
|
||||
onClick={() => setEditing({ index: i, hook: h })}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.automation_delete()}
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
{dirty && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md bg-[var(--warning)]/10 px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{m.automation_unsaved()}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setHooks(server ?? [])}
|
||||
>
|
||||
{m.display_revert()}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setConfirming(true)}>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<HookForm
|
||||
value={editing?.hook ?? null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={upsert}
|
||||
/>
|
||||
|
||||
{/* Saving installs commands the host will run on its own — same bar as an update or an
|
||||
unreviewed install, so the same password. */}
|
||||
<Dialog
|
||||
open={confirming}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setConfirming(false);
|
||||
setWrongPassword(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{m.automation_confirm_title()}</DialogTitle>
|
||||
<DialogDescription>{m.automation_confirm_body()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="automation-password">
|
||||
{m.store_spec_password()}
|
||||
</Label>
|
||||
<Input
|
||||
id="automation-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{wrongPassword && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{m.update_apply_wrong_password()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
setWrongPassword(false);
|
||||
}}
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={save.isPending || password.length === 0}
|
||||
onClick={commit}
|
||||
>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Activity as ActivityIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type ActivityEntry, useActivity } from "@/api/events";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* What this host has been doing — the event stream, rendered.
|
||||
*
|
||||
* The console could describe the present (a status snapshot) but never the recent past: a client
|
||||
* that connected and left while you were on another page left no trace anywhere you could look.
|
||||
* The stream was already open for cache invalidation, so this costs one ring buffer.
|
||||
*
|
||||
* In-memory and bounded, so it starts empty on a page load and fills as things happen. That is the
|
||||
* honest shape for a live tail — pretending to be a durable log would need the host to keep one.
|
||||
*/
|
||||
export const ActivityCard: FC = () => {
|
||||
const entries = useActivity();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
{m.activity_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{m.activity_empty()}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{entries.map((e) => (
|
||||
<li
|
||||
key={e.seq}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Badge variant={toneFor(e.kind)}>{kindLabel(e.kind)}</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{describe(e)}
|
||||
</span>
|
||||
<time
|
||||
dateTime={new Date(e.ts_ms).toISOString()}
|
||||
className="shrink-0 text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{fmtDateTime(e.ts_ms)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The subject of an event, in one line — whatever the payload actually names. */
|
||||
function describe(e: ActivityEntry): string {
|
||||
const d = e.data;
|
||||
const client = pick(d.client, "name") ?? pick(d.session, "client");
|
||||
const stream = d.stream as Record<string, unknown> | undefined;
|
||||
const parts = [
|
||||
client,
|
||||
typeof stream?.app === "string" ? stream.app : undefined,
|
||||
typeof d.reason === "string" ? d.reason : undefined,
|
||||
typeof d.game === "string" ? d.game : undefined,
|
||||
].filter((x): x is string => typeof x === "string" && x.length > 0);
|
||||
// An event whose payload names nothing (host.started, library.changed) is still worth a row —
|
||||
// the kind badge carries the whole meaning, so leave the line blank rather than inventing text.
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Read a string field off a nested ref object, tolerating anything unexpected. */
|
||||
function pick(obj: unknown, key: string): string | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
const v = (obj as Record<string, unknown>)[key];
|
||||
if (typeof v === "string") return v;
|
||||
// `SessionRef.client` is itself a ClientRef.
|
||||
if (v && typeof v === "object") {
|
||||
const name = (v as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Colour by what the event means, not by its domain — good news green, losses muted, denials red. */
|
||||
function toneFor(
|
||||
kind: string,
|
||||
): "success" | "destructive" | "secondary" | "outline" {
|
||||
if (kind === "pairing.denied") return "destructive";
|
||||
if (kind.endsWith(".connected") || kind.endsWith(".started"))
|
||||
return "success";
|
||||
if (kind === "pairing.completed") return "success";
|
||||
if (kind.endsWith(".disconnected") || kind.endsWith(".ended"))
|
||||
return "outline";
|
||||
if (kind.endsWith(".stopped") || kind.endsWith(".exited")) return "outline";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
/** Translated label per kind, falling back to the raw kind so a new host event still shows. */
|
||||
const KIND_LABEL: Record<string, () => string> = {
|
||||
"client.connected": () => m.activity_client_connected(),
|
||||
"client.disconnected": () => m.activity_client_disconnected(),
|
||||
"session.started": () => m.activity_session_started(),
|
||||
"session.ended": () => m.activity_session_ended(),
|
||||
"stream.started": () => m.activity_stream_started(),
|
||||
"stream.stopped": () => m.activity_stream_stopped(),
|
||||
"game.running": () => m.activity_game_running(),
|
||||
"game.exited": () => m.activity_game_exited(),
|
||||
"pairing.pending": () => m.activity_pairing_pending(),
|
||||
"pairing.completed": () => m.activity_pairing_completed(),
|
||||
"pairing.denied": () => m.activity_pairing_denied(),
|
||||
"display.created": () => m.activity_display_created(),
|
||||
"display.released": () => m.activity_display_released(),
|
||||
"library.changed": () => m.activity_library_changed(),
|
||||
"update.available": () => m.activity_update_available(),
|
||||
"update.applied": () => m.activity_update_applied(),
|
||||
"plugins.changed": () => m.activity_plugins_changed(),
|
||||
"store.changed": () => m.activity_store_changed(),
|
||||
"host.started": () => m.activity_host_started(),
|
||||
"host.stopping": () => m.activity_host_stopping(),
|
||||
};
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_LABEL[kind]?.() ?? kind;
|
||||
}
|
||||
@@ -31,11 +31,13 @@ export const RunningGames: FC<{
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{games.map((g) => (
|
||||
{games.map((g, i) => (
|
||||
<GameRow
|
||||
// A host can run the same title for two clients at once (native admits concurrent
|
||||
// sessions), so the title alone is not a key.
|
||||
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
|
||||
// sessions), so the title alone is not a key. The index is the last resort: every
|
||||
// grace row has a null `session_id`, so two waiting copies of the same title on the
|
||||
// same plane produced identical keys and React collapsed them into one row.
|
||||
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}:${i}`}
|
||||
game={g}
|
||||
art={coverFor(g, library)}
|
||||
onEnd={() => onEnd(g)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import type { FC } from "react";
|
||||
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
|
||||
import { useGetLibrary } from "@/api/gen/library/library";
|
||||
@@ -8,14 +9,26 @@ import {
|
||||
useRequestIdr,
|
||||
useStopSession,
|
||||
} from "@/api/gen/session/session";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { DashboardView } from "./view";
|
||||
|
||||
export const SectionDashboard: FC = () => {
|
||||
useLocale();
|
||||
const qc = useQueryClient();
|
||||
// Poll live status every 2s so the console tracks an active session.
|
||||
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
|
||||
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
|
||||
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
|
||||
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
|
||||
// safety net in case the stream is unavailable.
|
||||
const status = useGetStatus({
|
||||
query: {
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.video_streaming || (q.state.data?.games?.length ?? 0) > 0
|
||||
? 2_000
|
||||
: 15_000,
|
||||
},
|
||||
});
|
||||
// The catalog, for the running-game card's box art. Fetched once and held: a library scan touches
|
||||
// every installed store's on-disk metadata, so it must not ride the 2 s status poll.
|
||||
const library = useGetLibrary(undefined, {
|
||||
@@ -28,29 +41,71 @@ export const SectionDashboard: FC = () => {
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
|
||||
|
||||
/** Every session control reports its failure. These are the console's most consequential
|
||||
* buttons — stopping a session, ending a game — and a refusal used to be completely silent. */
|
||||
const failed = (fallback: string) => (e: unknown) =>
|
||||
toast.error(apiErrorMessage(e) ?? fallback);
|
||||
|
||||
/**
|
||||
* "End now" means two different things, and which one is right follows from the row's state: a
|
||||
* game whose session is still live ends by stopping that session (what then happens to the game
|
||||
* follows the operator's policy — stopping a session is not licence to close a game), while a
|
||||
* game already waiting out its reconnect window has no session left to stop and is ended directly.
|
||||
*
|
||||
* Both paths are wider than the row they are attached to, and neither used to say so:
|
||||
*
|
||||
* - `DELETE /session` is the host's ONLY stop and it tears down every live session
|
||||
* (mgmt/session.rs calls `quit_session` AND `session_status::stop_all_quit`). With two people
|
||||
* streaming, "End now" on one row kicked both. There is no per-session stop to call instead,
|
||||
* so the honest fix is to name the blast radius before doing it.
|
||||
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
|
||||
* row for an operator-typed command carries no `app_id` — so that row ended all of them.
|
||||
*/
|
||||
const onEndGame = (game: ActiveGame) => {
|
||||
const games = status.data?.games ?? [];
|
||||
if (game.state === "grace") {
|
||||
const waiting = games.filter((g) => g.state === "grace").length;
|
||||
if (
|
||||
!game.app_id &&
|
||||
waiting > 1 &&
|
||||
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
|
||||
)
|
||||
return;
|
||||
endGame.mutate(
|
||||
{ data: { app_id: game.app_id ?? null } },
|
||||
{ onSuccess: invalidate },
|
||||
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
|
||||
);
|
||||
} else {
|
||||
stop.mutate(undefined, { onSuccess: invalidate });
|
||||
return;
|
||||
}
|
||||
if (!confirmStopAll()) return;
|
||||
stop.mutate(undefined, {
|
||||
onSuccess: invalidate,
|
||||
onError: failed(m.action_stop_failed()),
|
||||
});
|
||||
};
|
||||
|
||||
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
|
||||
* session live, stopping is not a per-client action and the operator has to know that. */
|
||||
const confirmStopAll = (): boolean => {
|
||||
const active = status.data?.active_sessions ?? 0;
|
||||
if (active <= 1) return true;
|
||||
return confirm(m.action_stop_session_all_confirm({ count: active }));
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardView
|
||||
status={status}
|
||||
library={library.data}
|
||||
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
|
||||
onRequestIdr={() => idr.mutate(undefined)}
|
||||
onStopSession={() => {
|
||||
if (!confirmStopAll()) return;
|
||||
stop.mutate(undefined, {
|
||||
onSuccess: invalidate,
|
||||
onError: failed(m.action_stop_failed()),
|
||||
});
|
||||
}}
|
||||
onRequestIdr={() =>
|
||||
idr.mutate(undefined, { onError: failed(m.action_idr_failed()) })
|
||||
}
|
||||
onEndGame={onEndGame}
|
||||
isStopping={stop.isPending}
|
||||
isRequestingIdr={idr.isPending}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fmtNumber } from "@/lib/format";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { ActivityCard } from "./Activity";
|
||||
import { RunningGames } from "./RunningGames";
|
||||
|
||||
export const DashboardView: FC<{
|
||||
@@ -73,8 +75,13 @@ export const DashboardView: FC<{
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.status_pin_pending()}
|
||||
</span>
|
||||
{/* The whole value used to be "●" or "—": no text, no state, colour
|
||||
doing all the work — nothing for a screen reader to read out and
|
||||
nothing for anyone who can't tell the two badges apart. */}
|
||||
<Badge variant={s.pin_pending ? "default" : "outline"}>
|
||||
{s.pin_pending ? "●" : "—"}
|
||||
{s.pin_pending
|
||||
? m.status_pin_waiting()
|
||||
: m.status_pin_none()}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -138,7 +145,34 @@ export const DashboardView: FC<{
|
||||
/>
|
||||
<Field
|
||||
label={m.stream_bitrate()}
|
||||
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
|
||||
value={`${fmtNumber(s.stream.bitrate_kbps / 1000, 1)} Mbps`}
|
||||
/>
|
||||
{/* Bring-up and reconfigure cost, the parity floor and the packet
|
||||
size: the host has reported all four for as long as this
|
||||
endpoint has existed and the console showed none of them, so
|
||||
"it takes ages to start" and "it hitches when I resize" had no
|
||||
number attached anywhere. Native-plane only — null on
|
||||
GameStream and null until the first frame lands, so the two
|
||||
timings appear only once they mean something. */}
|
||||
{s.stream.time_to_first_frame_ms != null && (
|
||||
<Field
|
||||
label={m.stream_first_frame()}
|
||||
value={`${fmtNumber(s.stream.time_to_first_frame_ms)} ms`}
|
||||
/>
|
||||
)}
|
||||
{s.stream.last_resize_ms != null && (
|
||||
<Field
|
||||
label={m.stream_last_resize()}
|
||||
value={`${fmtNumber(s.stream.last_resize_ms)} ms`}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label={m.stream_packet_size()}
|
||||
value={`${fmtNumber(s.stream.packet_size)} B`}
|
||||
/>
|
||||
<Field
|
||||
label={m.stream_min_fec()}
|
||||
value={fmtNumber(s.stream.min_fec)}
|
||||
/>
|
||||
</dl>
|
||||
) : (
|
||||
@@ -148,6 +182,9 @@ export const DashboardView: FC<{
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Below the session card: the past, under the present. */}
|
||||
<ActivityCard />
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
@@ -7,10 +8,10 @@ import {
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
getGetDisplaySettingsQueryKey,
|
||||
getGetDisplayStateQueryKey,
|
||||
@@ -41,6 +42,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
@@ -96,6 +98,53 @@ export const DisplaySection: FC = () => {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Save the hand-edited Custom block.
|
||||
*
|
||||
* `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this
|
||||
* form — but it is a field of the same policy object, so a draft seeded before the operator
|
||||
* changed the streamed screen still carried the OLD value and Save quietly put it back. Defer
|
||||
* that one axis to whatever the server currently reports.
|
||||
*/
|
||||
/** The streamed-screen pin as the HOST currently has it. Every write path defers to this rather
|
||||
* than to the draft: the draft is only re-seeded while it is CLEAN, so once the operator has an
|
||||
* unsaved edit its `capture_monitor` is frozen at whatever it was before they used the picker
|
||||
* below — and any write that spreads the draft would put the old pin back. */
|
||||
const serverCaptureMonitor = () => q.data?.settings.capture_monitor ?? null;
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return;
|
||||
apply({ ...draft, capture_monitor: serverCaptureMonitor() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits
|
||||
* along for the ride.
|
||||
*
|
||||
* These three controls apply immediately by design, but they used to send `{...draft}`: flipping
|
||||
* DDC while the Custom block held unsaved edits committed those edits too, and the shared
|
||||
* `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so
|
||||
* the operator got a policy they never saved with no trace it had happened. Send the axis on top
|
||||
* of the last SAVED policy, and merge only that axis back into the draft.
|
||||
*/
|
||||
const applyAxis = (patch: Partial<DisplayPolicy>) => {
|
||||
const base = seeded.current ?? draft;
|
||||
if (!base) return;
|
||||
// Reflect the flip straight away, keeping every other unsaved edit intact.
|
||||
setDraft((d) => (d ? { ...d, ...patch } : d));
|
||||
save.mutate(
|
||||
{ data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
seeded.current = res.settings;
|
||||
setDraft((d) => (d ? { ...d, ...patch } : res.settings));
|
||||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||||
toast.success(m.display_settings_saved());
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental
|
||||
// toggle), so the draft can silently diverge from what the host is actually running. Reading the
|
||||
// ref during render is safe here because every write to it is paired with a `setDraft`, so a
|
||||
@@ -108,14 +157,17 @@ export const DisplaySection: FC = () => {
|
||||
if (seeded.current) setDraft(seeded.current);
|
||||
};
|
||||
|
||||
// Last line of defence: a reload/close with pending edits loses them silently otherwise. The
|
||||
// browser shows its own generic wording — the text is ignored, only returning a value counts.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
// Don't lose pending edits, whichever way the operator leaves.
|
||||
//
|
||||
// This used to be a bare `beforeunload` listener, which only covers a reload or a tab close —
|
||||
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
|
||||
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
|
||||
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
|
||||
useBlocker({
|
||||
shouldBlockFn: () => !confirm(m.display_discard_confirm()),
|
||||
enableBeforeUnload: () => dirty,
|
||||
disabled: !dirty,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
@@ -132,18 +184,34 @@ export const DisplaySection: FC = () => {
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.host_displays_help()}
|
||||
</p>
|
||||
{/* Once the form is on screen, a FAILED BACKGROUND POLL must not replace it — the
|
||||
operator may be mid-edit, and swapping the card for an error box throws the
|
||||
draft away to report a refetch we could simply retry. Only a failure with
|
||||
nothing to show is worth the error state. */}
|
||||
<QueryState
|
||||
isLoading={q.isLoading}
|
||||
error={q.error}
|
||||
error={draft ? undefined : q.error}
|
||||
refetch={q.refetch}
|
||||
>
|
||||
{draft && q.error && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
>
|
||||
{m.display_refresh_failed()}
|
||||
</p>
|
||||
)}
|
||||
{q.data && draft && (
|
||||
<DisplayForm
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
presets={q.data.presets}
|
||||
customPresets={q.data.custom_presets}
|
||||
serverEffective={q.data.effective}
|
||||
serverCaptureMonitor={serverCaptureMonitor}
|
||||
apply={apply}
|
||||
applyAxis={applyAxis}
|
||||
saveDraft={saveDraft}
|
||||
busy={save.isPending}
|
||||
dirty={dirty}
|
||||
revert={revert}
|
||||
@@ -180,7 +248,15 @@ const DisplayForm: FC<{
|
||||
setDraft: (p: DisplayPolicy) => void;
|
||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||
customPresets: CustomPreset[];
|
||||
/** What the host reports as IN FORCE right now — not derived from the local draft. */
|
||||
serverEffective: EffectivePolicy;
|
||||
/** The streamed-screen pin as the host has it — the draft's copy goes stale while dirty. */
|
||||
serverCaptureMonitor: () => string | null;
|
||||
apply: (p: DisplayPolicy) => void;
|
||||
/** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */
|
||||
applyAxis: (patch: Partial<DisplayPolicy>) => void;
|
||||
/** Commit the Custom block, deferring axes this form does not own to the server's value. */
|
||||
saveDraft: () => void;
|
||||
busy: boolean;
|
||||
/** The draft differs from what the host has stored — drives the save bar + the discard guard. */
|
||||
dirty: boolean;
|
||||
@@ -192,7 +268,11 @@ const DisplayForm: FC<{
|
||||
setDraft,
|
||||
presets,
|
||||
customPresets,
|
||||
serverEffective,
|
||||
serverCaptureMonitor,
|
||||
apply,
|
||||
applyAxis,
|
||||
saveDraft,
|
||||
busy,
|
||||
dirty,
|
||||
revert,
|
||||
@@ -254,8 +334,8 @@ const DisplayForm: FC<{
|
||||
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||
// Which screen we stream is not a display-behavior axis at all — swapping the
|
||||
// streamed screen out from under the operator because they changed a preset would be
|
||||
// the worst kind of surprise.
|
||||
capture_monitor: draft.capture_monitor ?? null,
|
||||
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
|
||||
capture_monitor: serverCaptureMonitor(),
|
||||
});
|
||||
} else {
|
||||
apply({ ...draft, preset: id as Preset });
|
||||
@@ -277,8 +357,9 @@ const DisplayForm: FC<{
|
||||
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
|
||||
// the draft, so anything not named here is silently dropped — which is exactly how
|
||||
// applying a saved preset used to switch a mirroring host back to a virtual display
|
||||
// (found on-glass, .136). Every orthogonal axis has to be listed.
|
||||
capture_monitor: draft.capture_monitor ?? null,
|
||||
// (found on-glass, .136). Every orthogonal axis has to be listed, and this one comes
|
||||
// from the SERVER (see serverCaptureMonitor).
|
||||
capture_monitor: serverCaptureMonitor(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -487,11 +568,13 @@ const DisplayForm: FC<{
|
||||
<Field
|
||||
label={m.display_keep_alive()}
|
||||
help={m.display_keep_alive_help()}
|
||||
group
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ka.mode === "off" ? "default" : "outline"}
|
||||
aria-pressed={ka.mode === "off"}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, keep_alive: { mode: "off" } })
|
||||
@@ -502,6 +585,7 @@ const DisplayForm: FC<{
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ka.mode === "duration" ? "default" : "outline"}
|
||||
aria-pressed={ka.mode === "duration"}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
@@ -515,6 +599,7 @@ const DisplayForm: FC<{
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ka.mode === "forever" ? "default" : "outline"}
|
||||
aria-pressed={ka.mode === "forever"}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, keep_alive: { mode: "forever" } })
|
||||
@@ -525,6 +610,8 @@ const DisplayForm: FC<{
|
||||
{ka.mode === "duration" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="display-keep-alive-seconds"
|
||||
aria-label={m.display_keep_alive_seconds()}
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
@@ -594,8 +681,9 @@ const DisplayForm: FC<{
|
||||
}
|
||||
/>
|
||||
|
||||
<Field label={m.display_max()}>
|
||||
<Field label={m.display_max()} htmlFor="display-max">
|
||||
<Input
|
||||
id="display-max"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
@@ -637,7 +725,7 @@ const DisplayForm: FC<{
|
||||
{m.display_revert()}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => apply(draft)} disabled={busy || !dirty}>
|
||||
<Button onClick={saveDraft} disabled={busy || !dirty}>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -654,11 +742,7 @@ const DisplayForm: FC<{
|
||||
options={["auto", "dedicated"]}
|
||||
labels={GAME_SESSION_LABEL}
|
||||
disabled={busy}
|
||||
onPick={(v) => {
|
||||
const next = { ...draft, game_session: v as GameSession };
|
||||
setDraft(next);
|
||||
apply(next);
|
||||
}}
|
||||
onPick={(v) => applyAxis({ game_session: v as GameSession })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -671,11 +755,7 @@ const DisplayForm: FC<{
|
||||
offLabel={m.display_ddc_disabled()}
|
||||
onLabel={m.display_ddc_enabled()}
|
||||
busy={busy}
|
||||
onSet={(on) => {
|
||||
const next = { ...draft, ddc_power_off: on };
|
||||
setDraft(next);
|
||||
apply(next);
|
||||
}}
|
||||
onSet={(on) => applyAxis({ ddc_power_off: on })}
|
||||
/>
|
||||
<ExperimentalToggle
|
||||
label={m.display_pnp()}
|
||||
@@ -684,32 +764,32 @@ const DisplayForm: FC<{
|
||||
offLabel={m.display_pnp_disabled()}
|
||||
onLabel={m.display_pnp_enabled()}
|
||||
busy={busy}
|
||||
onSet={(on) => {
|
||||
const next = { ...draft, pnp_disable_monitors: on };
|
||||
setDraft(next);
|
||||
apply(next);
|
||||
}}
|
||||
onSet={(on) => applyAxis({ pnp_disable_monitors: on })}
|
||||
/>
|
||||
|
||||
{/* What's in force right now */}
|
||||
{/* What's in force right now — read from the API's `effective`, not from the local draft.
|
||||
Deriving it from the draft meant the row restated the operator's unsaved edits back to
|
||||
them as though the host had already adopted them. */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.display_effective()}:
|
||||
</span>
|
||||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{tr(TOPOLOGY_LABEL, effective.topology)}
|
||||
{fmtKeepAlive(serverEffective.keep_alive)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{tr(TOPOLOGY_LABEL, serverEffective.topology)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(CONFLICT_LABEL, effective.mode_conflict)}
|
||||
{tr(CONFLICT_LABEL, serverEffective.mode_conflict)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(IDENTITY_LABEL, effective.identity)}
|
||||
{tr(IDENTITY_LABEL, serverEffective.identity)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{tr(LAYOUT_LABEL, effective.layout.mode)}
|
||||
{tr(LAYOUT_LABEL, serverEffective.layout.mode)}
|
||||
</Badge>
|
||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||
<Badge variant="outline">{`${serverEffective.max_displays}×`}</Badge>
|
||||
{(draft.game_session ?? "auto") === "dedicated" && (
|
||||
<Badge variant="secondary">
|
||||
{m.display_game_session_dedicated()}
|
||||
@@ -735,19 +815,46 @@ const DisplayForm: FC<{
|
||||
|
||||
/** A labeled config field — label, then the control, then optional help. The single source of the
|
||||
* label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */
|
||||
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
|
||||
label,
|
||||
help,
|
||||
children,
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
<Label className="block">{label}</Label>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const Field: FC<{
|
||||
label: string;
|
||||
help?: string;
|
||||
children: ReactNode;
|
||||
/** The id of the single control this labels, when there is one — see below. */
|
||||
htmlFor?: string;
|
||||
/** Set when the field wraps a GROUP of controls rather than one input. */
|
||||
group?: boolean;
|
||||
}> = ({ label, help, children, htmlFor, group }) => {
|
||||
const helpId = help && htmlFor ? `${htmlFor}-help` : undefined;
|
||||
const helpText = help && (
|
||||
<p id={helpId} className="max-w-prose text-xs text-muted-foreground">
|
||||
{help}
|
||||
</p>
|
||||
);
|
||||
// A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role.
|
||||
// (The single-control case keeps a plain <label for>, which is the right pairing there.)
|
||||
if (group) {
|
||||
return (
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-3 block text-sm font-medium leading-none">
|
||||
{label}
|
||||
</legend>
|
||||
{children}
|
||||
{helpText}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
// A bare <Label> with no `htmlFor` next to an <input> with no `id` labels nothing at all: a
|
||||
// screen reader announced these as unnamed spin buttons.
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="block" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helpText}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An Experimental-badged on/off policy toggle (the DDC/CI and PnP monitor axes) — rendered outside
|
||||
@@ -763,19 +870,21 @@ const ExperimentalToggle: FC<{
|
||||
onSet: (v: boolean) => void;
|
||||
}> = ({ label, help, value, offLabel, onLabel, busy, onSet }) => (
|
||||
<div className="border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="block">{label}</Label>
|
||||
{/* A labelled group: the pair of buttons is one control, and the label belongs to both. */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-3 flex items-center gap-2 text-sm font-medium leading-none">
|
||||
{label}
|
||||
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
|
||||
{m.display_experimental()}
|
||||
</Badge>
|
||||
</div>
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([false, true] as const).map((on) => (
|
||||
<Button
|
||||
key={String(on)}
|
||||
size="sm"
|
||||
variant={value === on ? "default" : "outline"}
|
||||
aria-pressed={value === on}
|
||||
disabled={busy}
|
||||
onClick={() => onSet(on)}
|
||||
>
|
||||
@@ -784,7 +893,7 @@ const ExperimentalToggle: FC<{
|
||||
))}
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -798,13 +907,17 @@ const Choice: FC<{
|
||||
disabled: boolean;
|
||||
onPick: (v: string) => void;
|
||||
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
|
||||
<Field label={label} help={help}>
|
||||
<Field label={label} help={help} group>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((o) => (
|
||||
<Button
|
||||
key={o}
|
||||
size="sm"
|
||||
variant={value === o ? "default" : "outline"}
|
||||
// Which option is active was signalled by fill colour alone — invisible to a screen
|
||||
// reader, and to anyone who can't separate the two variants. `aria-pressed` states it.
|
||||
// (The sibling Choice in SessionGameCard already did this; these did not.)
|
||||
aria-pressed={value === o}
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(o)}
|
||||
>
|
||||
@@ -842,8 +955,13 @@ const CustomPresetCard: FC<{
|
||||
tabIndex={busy ? -1 : 0}
|
||||
aria-pressed={selected}
|
||||
aria-disabled={busy || undefined}
|
||||
aria-label={m.display_preset_apply_named({ name: preset.name })}
|
||||
onClick={() => !busy && onApply()}
|
||||
onKeyDown={(e) => {
|
||||
// Only when the CARD itself has focus. Keydown bubbles, so Enter/Space on the
|
||||
// rename/update/delete icons inside it also reached here and applied the preset
|
||||
// instead of running the icon's action — a keyboard user could not delete a preset.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (!busy) onApply();
|
||||
@@ -918,7 +1036,17 @@ const CustomPresetCard: FC<{
|
||||
*/
|
||||
const LiveDisplays: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
|
||||
// Create/release arrive on the event stream (api/events.ts), so the timer is only here for the
|
||||
// one thing events cannot express: the per-second "tears down in Ns" countdown on a lingering
|
||||
// display. With nothing lingering it drops to a slow safety net.
|
||||
const state = useGetDisplayState({
|
||||
query: {
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.displays?.some((d) => d.expires_in_ms != null)
|
||||
? 2_000
|
||||
: 15_000,
|
||||
},
|
||||
});
|
||||
const release = useReleaseDisplay();
|
||||
const displays = state.data?.displays ?? [];
|
||||
const kept = displays.filter((d) => d.state !== "active");
|
||||
@@ -986,22 +1114,44 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
|
||||
}) => {
|
||||
const qc = useQueryClient();
|
||||
const saveLayout = useSetDisplayLayout();
|
||||
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
|
||||
const arrangeable = displays.filter((d) => d.identity_slot != null);
|
||||
const settings = useGetDisplaySettings();
|
||||
// Every position the host has on file — including devices that are not connected right now.
|
||||
// `PUT /display/layout` REPLACES the whole map (`with_manual_layout` in pf-vdisplay builds a
|
||||
// fresh `Layout`), so anything missing from our payload is deleted. Seeding only from the live
|
||||
// displays therefore wiped the saved placement of every device that happened to be offline.
|
||||
const saved = settings.data?.settings.layout?.positions;
|
||||
|
||||
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
|
||||
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
|
||||
const arrangeable = useMemo(
|
||||
() => displays.filter((d) => d.identity_slot != null),
|
||||
[displays],
|
||||
);
|
||||
// Local edit buffer keyed by identity-slot string → {x, y}. `arrangeable` is memoised, and React
|
||||
// Query's structural sharing keeps `displays` identity-stable across polls that changed nothing,
|
||||
// so this effect runs when the set of displays actually changes rather than on every poll. It is
|
||||
// idempotent regardless — it only ever fills in slots it has not seen before.
|
||||
const [pos, setPos] = useState<Record<
|
||||
string,
|
||||
{ x: number; y: number }
|
||||
> | null>(null);
|
||||
useEffect(() => {
|
||||
if (pos === null && arrangeable.length > 0) {
|
||||
const seed: Record<string, { x: number; y: number }> = {};
|
||||
for (const d of arrangeable)
|
||||
seed[String(d.identity_slot)] = { x: d.x, y: d.y };
|
||||
setPos(seed);
|
||||
}
|
||||
}, [arrangeable, pos]);
|
||||
if (arrangeable.length === 0) return;
|
||||
setPos((prev) => {
|
||||
// Seed a display the first time we see it, and never re-seed one the operator may have
|
||||
// since edited: a display that appears mid-edit used to be left out of the buffer entirely
|
||||
// and so dropped from the save.
|
||||
const next = { ...(prev ?? {}) };
|
||||
let changed = prev === null;
|
||||
for (const d of arrangeable) {
|
||||
const k = String(d.identity_slot);
|
||||
if (!(k in next)) {
|
||||
next[k] = { x: d.x, y: d.y };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [arrangeable]);
|
||||
|
||||
if (arrangeable.length < 2) return null;
|
||||
const cur = pos ?? {};
|
||||
@@ -1013,7 +1163,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
|
||||
|
||||
const onSave = () =>
|
||||
saveLayout.mutate(
|
||||
{ data: { positions: cur } },
|
||||
// Saved-first, edits on top: the host replaces the whole map, so an absent device's
|
||||
// placement survives only if we send it back.
|
||||
{ data: { positions: { ...saved, ...cur } } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() });
|
||||
@@ -1126,15 +1278,6 @@ const DisplayRow: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||||
const apiErrorMessage = (err: unknown): string | undefined => {
|
||||
if (err instanceof ApiError) {
|
||||
const data = err.data as { error?: string } | undefined;
|
||||
return data?.error ?? err.message;
|
||||
}
|
||||
return err ? String(err) : undefined;
|
||||
};
|
||||
|
||||
/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty
|
||||
* now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and
|
||||
* freed via Release. */
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
useSetDisplaySettings,
|
||||
} from "@/api/gen/display/display";
|
||||
import type { ApiMonitorInfo } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
@@ -41,7 +41,11 @@ export const MonitorCard: FC = () => {
|
||||
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
|
||||
// environment is read-only here: offering controls that silently lose to the env would be worse
|
||||
// than saying so.
|
||||
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
|
||||
//
|
||||
// Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed)
|
||||
// `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env
|
||||
// pin that may not exist and go read-only on every slow load.
|
||||
const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned;
|
||||
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
|
||||
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
|
||||
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
|
||||
@@ -85,7 +89,11 @@ export const MonitorCard: FC = () => {
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
|
||||
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
|
||||
(busy || locked) && "cursor-not-allowed opacity-60",
|
||||
// `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own
|
||||
// virtual displays. It was styled exactly like a selectable row and silently swallowed
|
||||
// every click; it is listed so "why isn't my monitor here?" has an answer, so it has to
|
||||
// LOOK unavailable too.
|
||||
(busy || locked || !onSelect) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
|
||||
@@ -32,11 +32,18 @@ export const SessionGameCard: FC = () => {
|
||||
const q = useGetSessionSettings();
|
||||
const save = useSetSessionSettings();
|
||||
const server = q.data?.settings;
|
||||
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
|
||||
// controls are shown disabled rather than hidden — "does nothing here" is information.
|
||||
const enforced = q.data?.enforced ?? [];
|
||||
const acts = (field: string) =>
|
||||
enforced.length === 0 || enforced.includes(field);
|
||||
// Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract
|
||||
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
|
||||
// instead of offering a switch that does nothing"), and this card's own comment promises the
|
||||
// controls are "shown disabled rather than hidden".
|
||||
//
|
||||
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
|
||||
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
|
||||
// toasted success for an axis the host would never act on. Absent (an older host that never
|
||||
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
|
||||
// different case from present-and-empty.
|
||||
const enforced = q.data?.enforced;
|
||||
const acts = (field: string) => !enforced || enforced.includes(field);
|
||||
|
||||
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
|
||||
// are discrete and go straight to the host.
|
||||
@@ -76,6 +83,7 @@ export const SessionGameCard: FC = () => {
|
||||
<Field
|
||||
label={m.session_game_on_exit()}
|
||||
help={m.session_game_on_exit_help()}
|
||||
group
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Choice
|
||||
@@ -98,6 +106,7 @@ export const SessionGameCard: FC = () => {
|
||||
<Field
|
||||
label={m.session_game_end_game()}
|
||||
help={m.session_game_end_game_help()}
|
||||
group
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{END_POLICIES.map((p) => (
|
||||
@@ -130,9 +139,11 @@ export const SessionGameCard: FC = () => {
|
||||
<Field
|
||||
label={m.session_game_grace()}
|
||||
help={m.session_game_grace_help()}
|
||||
htmlFor="session-grace-seconds"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="session-grace-seconds"
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
@@ -162,7 +173,10 @@ export const SessionGameCard: FC = () => {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{enforced.length === 0 && (
|
||||
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
|
||||
is an older host that never sent the field, where claiming inertness
|
||||
would be a guess. Same distinction `acts()` makes above. */}
|
||||
{enforced?.length === 0 && (
|
||||
<Badge variant="outline">{m.session_game_inert()}</Badge>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
@@ -180,19 +194,45 @@ const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
|
||||
always: () => m.session_game_end_always(),
|
||||
};
|
||||
|
||||
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
|
||||
label,
|
||||
help,
|
||||
children,
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
<Label className="block">{label}</Label>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
|
||||
*
|
||||
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all — the grace input was
|
||||
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
|
||||
* separate on purpose (this card's axes are its own).
|
||||
*/
|
||||
const Field: FC<{
|
||||
label: string;
|
||||
help?: string;
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
group?: boolean;
|
||||
}> = ({ label, help, children, htmlFor, group }) => {
|
||||
const body = (
|
||||
<>
|
||||
<Label className="block" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return group ? (
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-3 block text-sm font-medium leading-none">
|
||||
{label}
|
||||
</legend>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
) : (
|
||||
<div className="space-y-3">{body}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Choice: FC<{
|
||||
selected: boolean;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useGetLocalSummary } from "@/api/gen/host/host";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* "Something else is already listening on these ports."
|
||||
*
|
||||
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) running on the same
|
||||
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
|
||||
* even though it is the single most common reason a punktfunk host looks installed and working but
|
||||
* no client can reach it — two servers fighting over the same ports, with whichever won the bind
|
||||
* answering the client.
|
||||
*
|
||||
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
|
||||
*/
|
||||
export const ConflictsCard: FC = () => {
|
||||
// Static per host boot (the host probes once at startup), so there is nothing to poll for.
|
||||
const summary = useGetLocalSummary({ query: { staleTime: 5 * 60_000 } });
|
||||
const conflicts = summary.data?.conflicts ?? [];
|
||||
if (conflicts.length === 0) return null;
|
||||
return (
|
||||
<Card className="border-amber-600/40 dark:border-amber-500/40">
|
||||
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
|
||||
{m.host_conflicts_title()}
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.host_conflicts_help()}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{conflicts.map((c) => (
|
||||
<li
|
||||
key={c}
|
||||
className="rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Check, Copy, Smartphone } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* "Get a device onto this host" — the address to type, and the deep link that skips typing it.
|
||||
*
|
||||
* The console knew the host's identity and local address all along and never offered either in a
|
||||
* form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on
|
||||
* a couch. `punktfunk://connect/<unique_id>` is the shipped client grammar
|
||||
* (clients/shared/deeplink-vectors.json — the Rust, Swift and Kotlin parsers all test against it),
|
||||
* so a client that is already installed opens straight onto this host.
|
||||
*
|
||||
* No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none.
|
||||
* The link is short enough to send over any chat app, which is what people actually do.
|
||||
*/
|
||||
export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => {
|
||||
const deepLink = `punktfunk://connect/${host.uniqueid}`;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
{m.connect_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.connect_help()}
|
||||
</p>
|
||||
<CopyRow label={m.connect_address()} value={host.local_ip} />
|
||||
<CopyRow label={m.connect_link()} value={deepLink} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** One labelled, monospaced value with a copy button — the point of the card. */
|
||||
const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
// Revert the affordance rather than leaving a permanent tick, which would stop reading as
|
||||
// feedback the second time you press it.
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard denied (insecure origin, or the user said no) — the value is on screen and
|
||||
// selectable, so there is nothing worth interrupting them about.
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={m.connect_copy()}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[var(--success)]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getListGpusQueryKey,
|
||||
@@ -10,6 +11,7 @@ import type { GpuState } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
@@ -20,15 +22,20 @@ import { m } from "@/paraglide/messages";
|
||||
*/
|
||||
export const GpuSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const gpus = useListGpus({ query: { refetchInterval: 5_000 } });
|
||||
// GPU state only moves when a session starts or ends, which the event stream reports — so this
|
||||
// is a slow safety net rather than a 5 s poll of a device enumeration.
|
||||
const gpus = useListGpus({ query: { refetchInterval: 20_000 } });
|
||||
const setPref = useSetGpuPreference();
|
||||
|
||||
// A refused GPU preference used to vanish: nothing read `setPref.error`, so the card simply
|
||||
// stayed on the old selection as though the click had missed.
|
||||
const apply = (mode: "auto" | "manual", gpuId?: string) =>
|
||||
setPref.mutate(
|
||||
{ data: { mode, gpu_id: gpuId ?? null } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: getListGpusQueryKey() }),
|
||||
onError: (e) => toast.error(apiErrorMessage(e) ?? m.gpu_apply_failed()),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import type { UpdateStatus } from "@/api/gen/model";
|
||||
import {
|
||||
getGetUpdateStatusQueryKey,
|
||||
useForceUpdateCheck,
|
||||
useGetUpdateStatus,
|
||||
} from "@/api/gen/update/update";
|
||||
import type { UpdateStatus } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { fmtDateTimeSecs } from "@/lib/format";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
@@ -67,6 +71,14 @@ export const UpdateSection: FC = () => {
|
||||
onSuccess: (fresh) => {
|
||||
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
|
||||
},
|
||||
// The host throttles repeat checks (429). Swallowing it made a second click within the
|
||||
// window look like a dead button; say the check was skipped and why.
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof ApiError && e.status === 429
|
||||
? m.update_apply_throttled()
|
||||
: (apiErrorMessage(e) ?? m.update_error()),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -75,9 +87,8 @@ export const UpdateSection: FC = () => {
|
||||
onCheck={checkNow}
|
||||
checkBusy={check.isPending}
|
||||
applying={applying}
|
||||
onApplied={(target) =>
|
||||
setApplying({ target, startedAt: Date.now() })
|
||||
}
|
||||
onApplied={(target) => setApplying({ target, startedAt: Date.now() })}
|
||||
onGiveUp={() => setApplying(null)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -88,11 +99,18 @@ export const UpdateCard: FC<{
|
||||
checkBusy: boolean;
|
||||
applying: { target: string; startedAt: number } | null;
|
||||
onApplied: (target: string) => void;
|
||||
}> = ({ state, onCheck, checkBusy, applying, onApplied }) => {
|
||||
/** Leave the applying state after a timeout — otherwise the card waits forever. */
|
||||
onGiveUp: () => void;
|
||||
}> = ({ state, onCheck, checkBusy, applying, onApplied, onGiveUp }) => {
|
||||
const s = state.data;
|
||||
const inFlight = Boolean(applying) || Boolean(s?.job);
|
||||
const timedOut =
|
||||
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
|
||||
// Is the snapshot we are rendering still being refreshed? While the host is gone the query
|
||||
// keeps its LAST payload — including a `job` that was in progress when it went away — so the
|
||||
// timeout warning, gated on `!job`, could never fire in the one case it exists for. A failing
|
||||
// poll means the job field describes a host we can no longer see.
|
||||
const snapshotStale = Boolean(state.error);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
@@ -156,6 +174,8 @@ export const UpdateCard: FC<{
|
||||
status={s}
|
||||
reconnecting={Boolean(state.error)}
|
||||
timedOut={timedOut}
|
||||
snapshotStale={snapshotStale}
|
||||
onGiveUp={onGiveUp}
|
||||
/>
|
||||
) : s.available ? (
|
||||
s.apply === "full" || s.apply === "staged" ? (
|
||||
@@ -214,7 +234,7 @@ export const UpdateCard: FC<{
|
||||
{s.last_checked_unix != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.update_last_checked()}{" "}
|
||||
{new Date(s.last_checked_unix * 1000).toLocaleString()}
|
||||
{fmtDateTimeSecs(s.last_checked_unix)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -356,7 +376,10 @@ const ApplyProgress: FC<{
|
||||
status: UpdateStatus;
|
||||
reconnecting: boolean;
|
||||
timedOut: boolean;
|
||||
}> = ({ status, reconnecting, timedOut }) => {
|
||||
/** The rendered snapshot can no longer be refreshed — its `job` may describe a vanished host. */
|
||||
snapshotStale: boolean;
|
||||
onGiveUp: () => void;
|
||||
}> = ({ status, reconnecting, timedOut, snapshotStale, onGiveUp }) => {
|
||||
const job = status.job;
|
||||
const pct =
|
||||
job?.total_bytes && job.total_bytes > 0
|
||||
@@ -396,12 +419,18 @@ const ApplyProgress: FC<{
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out" —
|
||||
the warning is for the host being GONE longer than a restart explains. */}
|
||||
{timedOut && !job && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_apply_timeout()}
|
||||
</p>
|
||||
{/* A job we can still SEE progressing (e.g. the Deck's tens-of-minutes source rebuild) is
|
||||
not "timed out" — the warning is for the host being GONE longer than a restart
|
||||
explains. A stale snapshot's job does not count as seeing one. */}
|
||||
{timedOut && (!job || snapshotStale) && (
|
||||
<div className="space-y-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<p>{m.update_apply_timeout()}</p>
|
||||
{/* Without this the card sits in "applying" forever and the operator cannot even
|
||||
re-check — the state was only ever cleared by a status that never arrives. */}
|
||||
<Button variant="outline" size="sm" onClick={onGiveUp}>
|
||||
{m.update_apply_give_up()}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { ConflictsCard } from "./ConflictsCard";
|
||||
import { GpuSection } from "./GpuCard";
|
||||
import { UpdateSection } from "./UpdateCard";
|
||||
import { HostView } from "./view";
|
||||
@@ -14,6 +15,7 @@ export const SectionHost: FC = () => {
|
||||
<HostView
|
||||
host={host}
|
||||
compositors={compositors}
|
||||
conflicts={<ConflictsCard />}
|
||||
gpu={<GpuSection />}
|
||||
update={<UpdateSection />}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { ConnectCard } from "./ConnectCard";
|
||||
|
||||
export const HostView: FC<{
|
||||
host: Loadable<HostInfo>;
|
||||
@@ -16,13 +17,19 @@ export const HostView: FC<{
|
||||
gpu?: ReactNode;
|
||||
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
|
||||
update?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, update }) => {
|
||||
/** Warning about other Moonlight-compatible servers on this machine — renders nothing when
|
||||
* there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */
|
||||
conflicts?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, update, conflicts }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||
|
||||
{conflicts}
|
||||
{h && <ConnectCard host={h} />}
|
||||
|
||||
<QueryState
|
||||
isLoading={host.isLoading}
|
||||
error={host.error}
|
||||
|
||||
@@ -40,7 +40,12 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
onDelete,
|
||||
deleting,
|
||||
}) => {
|
||||
const isCustom = game.store === "custom";
|
||||
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
|
||||
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
|
||||
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
|
||||
// buttons produced a failure the card never surfaced. Provider-owned entries are attributed
|
||||
// instead.
|
||||
const isCustom = game.store === "custom" && !game.provider;
|
||||
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
|
||||
const [failed, setFailed] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -79,6 +84,13 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
{game.platform}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Who owns this entry, when it isn't the operator — the reason the edit/delete
|
||||
buttons are absent here and present on the card next to it. */}
|
||||
{game.provider && (
|
||||
<Badge variant="outline" className="bg-background/80 backdrop-blur">
|
||||
{m.library_owned_by({ provider: game.provider })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { customId } from "./helpers";
|
||||
|
||||
@@ -133,10 +134,18 @@ export const GameFormSection: FC<{
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
|
||||
// A rejected save must not close the form and must not look like a success. It used to do both:
|
||||
// nothing read `create.error`/`update.error`, and the un-caught `mutateAsync` rejection meant
|
||||
// the entry silently didn't save while the dialog disappeared — taking the operator's typing
|
||||
// with it.
|
||||
const onSubmit = async (data: CustomInput) => {
|
||||
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
|
||||
else
|
||||
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
|
||||
try {
|
||||
if (target === "new") await create.mutateAsync({ data });
|
||||
else await update.mutateAsync({ id: customId(target), data });
|
||||
} catch {
|
||||
return; // the message is rendered from the mutation's own error state below
|
||||
}
|
||||
invalidate();
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -147,6 +156,7 @@ export const GameFormSection: FC<{
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onClose}
|
||||
isSaving={create.isPending || update.isPending}
|
||||
error={apiErrorMessage(create.error ?? update.error)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -187,7 +197,9 @@ export const GameForm: FC<{
|
||||
onSubmit: (data: CustomInput) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
|
||||
/** The host's refusal, if the last save failed — shown next to the button that caused it. */
|
||||
error?: string;
|
||||
}> = ({ initial, mode, onSubmit, onCancel, isSaving, error }) => {
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const set = (key: keyof FormState) => (value: string) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
@@ -331,6 +343,26 @@ export const GameForm: FC<{
|
||||
help={m.library_field_tags_help()}
|
||||
/>
|
||||
</fieldset>
|
||||
{/* Data-loss warning, not a nicety.
|
||||
`PUT /library/custom/{id}` REPLACES the entry (host: library/custom.rs
|
||||
`update_custom` assigns `slot.prep = input.prep; slot.detect = input.detect`),
|
||||
but `GET /library` returns a `GameEntry`, which carries neither field. So the
|
||||
console cannot round-trip them — anything configured outside this form is dropped
|
||||
by a save it did not intend to touch. The real fix is host-side (expose `detect`
|
||||
and `prep` on the read model); until then, say so before the operator finds out. */}
|
||||
{mode === "edit" && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{m.library_edit_overwrites()}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isSaving || !form.title.trim()}>
|
||||
{mode === "edit" ? m.library_save() : m.library_create()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useEffect, useMemo } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteCustomGame,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { GameCard } from "./GameCard";
|
||||
@@ -19,23 +21,51 @@ import { customId } from "./helpers";
|
||||
* Editing is escalated to the parent (it opens the separate add/edit form), so
|
||||
* this subsection knows nothing about the form beyond firing `onEdit`.
|
||||
*/
|
||||
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
|
||||
onEdit,
|
||||
}) => {
|
||||
export const LibraryGridSection: FC<{
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
/** Show only entries owned by this provider, or everything when null. */
|
||||
providerFilter?: string | null;
|
||||
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
|
||||
onEntries?: (entries: GameEntry[]) => void;
|
||||
}> = ({ onEdit, providerFilter, onEntries }) => {
|
||||
const qc = useQueryClient();
|
||||
const library = useGetLibrary();
|
||||
const all = library.data;
|
||||
useEffect(() => {
|
||||
if (all) onEntries?.(all);
|
||||
}, [all, onEntries]);
|
||||
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
|
||||
// list for the grid, and a second parameterised query would just be a second cache entry of the
|
||||
// same data going stale independently.
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
providerFilter
|
||||
? {
|
||||
...library,
|
||||
data: all?.filter((e) => e.provider === providerFilter),
|
||||
}
|
||||
: library,
|
||||
[library, all, providerFilter],
|
||||
);
|
||||
const remove = useDeleteCustomGame();
|
||||
|
||||
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
|
||||
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
|
||||
// of them — the card just stayed put as if nothing had been clicked.
|
||||
const onDelete = async (entry: GameEntry) => {
|
||||
if (!confirm(m.library_delete_confirm())) return;
|
||||
await remove
|
||||
.mutateAsync({ id: customId(entry) })
|
||||
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
|
||||
try {
|
||||
await remove.mutateAsync({ id: customId(entry) });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_delete_failed());
|
||||
return;
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
};
|
||||
|
||||
return (
|
||||
<LibraryGrid
|
||||
library={library}
|
||||
library={filtered}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
// The custom id whose delete is in flight (if any), so only that card's button disables.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteProviderEntries,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* Provider-owned entries: who put them there, and how to get rid of them.
|
||||
*
|
||||
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
|
||||
* delete individually — the host answers 409 and points at the provider's own reconcile. Which is
|
||||
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
|
||||
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
|
||||
* documented clean-uninstall path and nothing called it.
|
||||
*
|
||||
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
|
||||
*/
|
||||
export const ProvidersCard: FC<{
|
||||
entries: GameEntry[];
|
||||
/** The provider currently filtered to, or null for "everything". */
|
||||
active: string | null;
|
||||
onFilter: (provider: string | null) => void;
|
||||
}> = ({ entries, active, onFilter }) => {
|
||||
const qc = useQueryClient();
|
||||
const purge = useDeleteProviderEntries();
|
||||
|
||||
// Count per provider, in first-seen order — the list is small and operator-facing.
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
|
||||
}
|
||||
if (counts.size === 0) return null;
|
||||
|
||||
const onPurge = async (provider: string, count: number) => {
|
||||
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
|
||||
try {
|
||||
await purge.mutateAsync({ provider });
|
||||
// The host emits `library.changed`, but don't wait for the round trip to redraw.
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
if (active === provider) onFilter(null);
|
||||
toast.success(m.library_provider_purged({ provider }));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.library_providers_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.library_providers_help()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...counts.entries()].map(([provider, count]) => (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<span className="font-medium">{provider}</span>
|
||||
<Badge variant="secondary">
|
||||
{m.library_provider_count({ count })}
|
||||
</Badge>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={active === provider ? "default" : "outline"}
|
||||
aria-pressed={active === provider}
|
||||
onClick={() =>
|
||||
onFilter(active === provider ? null : provider)
|
||||
}
|
||||
>
|
||||
{active === provider
|
||||
? m.library_provider_show_all()
|
||||
: m.library_provider_filter()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={purge.isPending}
|
||||
aria-label={m.library_provider_purge()}
|
||||
onClick={() => onPurge(provider, count)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import { Plus } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { type FormTarget, GameFormSection } from "./GameForm";
|
||||
import { LibraryGridSection } from "./LibraryGrid";
|
||||
import { ProvidersCard } from "./Providers";
|
||||
import { SourceTogglesSection } from "./SourceToggles";
|
||||
|
||||
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
||||
@@ -16,6 +18,10 @@ export const SectionLibrary: FC = () => {
|
||||
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
|
||||
// by the target re-seeds its fields when switching add → edit (or between entries).
|
||||
const [target, setTarget] = useState<FormTarget | null>(null);
|
||||
// The full list, lifted from the grid so the providers card can count owners without a second
|
||||
// copy of the same query, plus which provider (if any) the grid is filtered to.
|
||||
const [entries, setEntries] = useState<GameEntry[]>([]);
|
||||
const [providerFilter, setProviderFilter] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => {
|
||||
|
||||
<SourceTogglesSection />
|
||||
|
||||
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||
<ProvidersCard
|
||||
entries={entries}
|
||||
active={providerFilter}
|
||||
onFilter={setProviderFilter}
|
||||
/>
|
||||
|
||||
<LibraryGridSection
|
||||
onEdit={(entry) => setTarget(entry)}
|
||||
providerFilter={providerFilter}
|
||||
onEntries={setEntries}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,8 @@ export const LogsSection: FC = () => {
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [dropped, setDropped] = useState(false);
|
||||
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
|
||||
// Set while a poll has failed and we have not yet re-read the ring from the start.
|
||||
const [resync, setResync] = useState(false);
|
||||
|
||||
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
|
||||
// on hydration. Until then the share button is simply absent.
|
||||
@@ -58,22 +60,58 @@ export const LogsSection: FC = () => {
|
||||
|
||||
const query = useLogsGet(
|
||||
{ after: cursor > 0 ? cursor : undefined },
|
||||
{ query: { refetchInterval: follow ? 2_000 : false } },
|
||||
{
|
||||
query: {
|
||||
refetchInterval: follow ? 2_000 : false,
|
||||
// Pausing must actually pause. Stopping only the interval left React Query's default
|
||||
// focus/reconnect refetches landing, and the append effect consumed them
|
||||
// unconditionally — so tabbing away and back evicted the lines the operator had
|
||||
// paused on, from behind the pause button.
|
||||
refetchOnWindowFocus: follow,
|
||||
refetchOnReconnect: follow,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Resync after the host goes away and comes back.
|
||||
//
|
||||
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
|
||||
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
|
||||
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
|
||||
// no error, no dropped badge and no way back short of a full reload. The console's own update
|
||||
// flow restarts the host, so this was reachable from two clicks away.
|
||||
//
|
||||
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
|
||||
// we re-read from the start of the ring once and let the effect below decide whether the
|
||||
// sequence actually regressed.
|
||||
const failed = query.isError;
|
||||
useEffect(() => {
|
||||
if (failed) setResync(true);
|
||||
}, [failed]);
|
||||
useEffect(() => {
|
||||
if (resync && cursor !== 0) setCursor(0);
|
||||
}, [resync, cursor]);
|
||||
|
||||
const data = query.data;
|
||||
useEffect(() => {
|
||||
if (!data || data.entries.length === 0) return;
|
||||
setEntries((prev) => {
|
||||
// Only append entries newer than what we already hold — dedup by the monotonic `seq`.
|
||||
// Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from
|
||||
// appending the same page twice (duplicate rows + duplicate React keys).
|
||||
const lastSeq = prev.at(-1)?.seq ?? -1;
|
||||
// A page whose newest entry is OLDER than what we already hold can only mean the host's
|
||||
// sequence restarted underneath us — the buffer describes a host that no longer exists,
|
||||
// so replace it wholesale rather than filtering every new line away as "already seen".
|
||||
const newest = data.entries.at(-1)?.seq ?? -1;
|
||||
if (newest < lastSeq) return data.entries.slice(-KEEP);
|
||||
// Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a
|
||||
// double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending
|
||||
// the same page twice (duplicate rows + duplicate React keys), and makes the post-resync
|
||||
// re-read from 0 a no-op when the host did NOT restart.
|
||||
const fresh = data.entries.filter((e) => e.seq > lastSeq);
|
||||
return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev;
|
||||
});
|
||||
setDropped((d) => d || data.dropped);
|
||||
setCursor(data.next);
|
||||
setResync(false);
|
||||
}, [data]);
|
||||
|
||||
// The card hands back the entries its filters currently match, so an export carries exactly what
|
||||
@@ -100,6 +138,9 @@ export const LogsSection: FC = () => {
|
||||
}}
|
||||
shareMode={shareMode}
|
||||
dropped={dropped}
|
||||
error={query.error}
|
||||
isLoading={query.isLoading}
|
||||
onRetry={() => query.refetch()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -118,6 +159,10 @@ export const LogsCard: FC<{
|
||||
onShare: (shown: LogEntry[]) => void;
|
||||
shareMode: ShareMode | null;
|
||||
dropped: boolean;
|
||||
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
|
||||
error?: unknown;
|
||||
isLoading?: boolean;
|
||||
onRetry?: () => void;
|
||||
}> = ({
|
||||
entries,
|
||||
follow,
|
||||
@@ -127,6 +172,9 @@ export const LogsCard: FC<{
|
||||
onShare,
|
||||
shareMode,
|
||||
dropped,
|
||||
error,
|
||||
isLoading,
|
||||
onRetry,
|
||||
}) => {
|
||||
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -146,16 +194,32 @@ export const LogsCard: FC<{
|
||||
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
|
||||
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
|
||||
|
||||
// Keep the tail in view while following (entries are append-only, so length is a good signal).
|
||||
// Keep the tail in view while following.
|
||||
//
|
||||
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
|
||||
// so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then
|
||||
// stopped re-running and follow-mode quietly stopped following — exactly when the log is busy
|
||||
// enough to need it. The newest seq keeps changing for as long as lines arrive.
|
||||
const newestVisible = visible.at(-1)?.seq ?? -1;
|
||||
// NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and
|
||||
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
|
||||
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
|
||||
// quietly stops following. The same warning was here before, on `visible.length`.
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [follow, visible.length]);
|
||||
}, [follow, newestVisible]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 pt-6">
|
||||
{/* This card has no CardHeader, so it has to put the top padding back itself — and it
|
||||
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
|
||||
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
|
||||
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
|
||||
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
|
||||
components/ui/card.tsx describes, in the other direction.) */}
|
||||
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((l) => (
|
||||
@@ -222,12 +286,41 @@ export const LogsCard: FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A failing poll while lines are already on screen keeps them there — during a host
|
||||
restart the last lines before it went away are the interesting ones — but says so,
|
||||
instead of letting a frozen view read as a quiet host. */}
|
||||
{error != null && entries.length > 0 && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{m.logs_stalled()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
|
||||
// An empty list has three quite different causes and used to render one sentence
|
||||
// for all of them: the host is quiet, the request failed, or it hasn't answered yet.
|
||||
<div className="p-2">
|
||||
{error ? (
|
||||
<div className="space-y-2 font-sans">
|
||||
<p className="text-destructive">{m.common_error()}</p>
|
||||
{onRetry && (
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
{m.common_retry()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{isLoading ? m.common_loading() : m.logs_empty()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
visible.map((e) => (
|
||||
<div key={e.seq} className="whitespace-pre-wrap break-words">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Info, KeyRound } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
|
||||
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
|
||||
import {
|
||||
getGetPairingStatusQueryKey,
|
||||
@@ -22,16 +23,37 @@ export const MoonlightPairingSection: FC = () => {
|
||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||
const submit = useSubmitPairingPin();
|
||||
|
||||
const onSubmit = () =>
|
||||
// Clear the previous attempt's outcome when a NEW pairing knock arrives.
|
||||
//
|
||||
// The mutation's success flag outlives the form — the section never unmounts, only the inner
|
||||
// <form> is conditional — so the green "PIN sent" note was still on screen above an empty PIN
|
||||
// box the next time Moonlight asked. Resetting inside `onSubmit` (the first attempt at this)
|
||||
// does nothing: `mutate` moves the status to pending in the same update, so `isSuccess` was
|
||||
// already about to go false. The transition that matters is `pin_pending` going false → true.
|
||||
const pending = pairing.data?.pin_pending ?? false;
|
||||
const wasPending = useRef(pending);
|
||||
useEffect(() => {
|
||||
if (pending && !wasPending.current) {
|
||||
submit.reset();
|
||||
setPin("");
|
||||
}
|
||||
wasPending.current = pending;
|
||||
}, [pending, submit.reset]);
|
||||
|
||||
const onSubmit = () => {
|
||||
submit.mutate(
|
||||
{ data: { pin } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setPin("");
|
||||
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
|
||||
// The success message tells the operator to check the paired list, so refresh it —
|
||||
// both planes, since this card's count spans them.
|
||||
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MoonlightPairing
|
||||
|
||||
@@ -24,7 +24,10 @@ import { m } from "@/paraglide/messages";
|
||||
*/
|
||||
export const PendingDevicesSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
|
||||
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
|
||||
// the rows carry an age that should not visibly lag.
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
|
||||
const approve = useApprovePendingDevice();
|
||||
const deny = useDenyPendingDevice();
|
||||
|
||||
|
||||
@@ -34,19 +34,32 @@ export const SectionPlugin: FC = () => {
|
||||
const { data: installed } = useInstalledPlugins();
|
||||
const provenance = installed?.find((p) => p.plugin_id === pluginId);
|
||||
|
||||
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
|
||||
// offline card (the manual Retry re-probes).
|
||||
// Liveness: a 200 from /__health means the plugin is up.
|
||||
//
|
||||
// Two subtleties, both learned the hard way:
|
||||
//
|
||||
// - A 200 is not enough. `fetch` follows redirects, so an expired session — where the gate
|
||||
// answers 302 → /login → 200 HTML — looked exactly like a healthy plugin, and the console
|
||||
// rendered its own login page inside the plugin's iframe. `redirect: "manual"` makes that
|
||||
// an opaque response we can reject instead.
|
||||
// - One failure must not be terminal. The runner is restarted at the end of every successful
|
||||
// install, so a single missed probe is routine; giving up on the first one threw away
|
||||
// whatever the operator had open in another plugin. Retry a few times, and keep probing on a
|
||||
// slower beat while down so it recovers on its own.
|
||||
const health = useQuery({
|
||||
queryKey: ["plugin-health", pluginId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
});
|
||||
// `type === "opaqueredirect"` is the gate bouncing us to /login, not the plugin answering.
|
||||
if (r.type === "opaqueredirect") throw new Error("session expired");
|
||||
if (!r.ok) throw new Error(`health ${r.status}`);
|
||||
return true;
|
||||
},
|
||||
retry: false,
|
||||
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
|
||||
retry: 2,
|
||||
refetchInterval: (q) => (q.state.status === "error" ? 5_000 : 20_000),
|
||||
});
|
||||
|
||||
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Circle, Square } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { StatsStatus } from "@/api/gen/model/statsStatus";
|
||||
@@ -13,6 +14,7 @@ import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { fmtDuration, kindLabel, Stat } from "./helpers";
|
||||
@@ -29,13 +31,21 @@ export const CaptureControlSection: FC = () => {
|
||||
|
||||
const refreshStatus = () =>
|
||||
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
|
||||
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
|
||||
// Both paths report failure. A failed STOP is the one that matters: it is "stop & save", so
|
||||
// swallowing the error let a capture the operator had been recording for minutes disappear with
|
||||
// no recording written and nothing on screen to say so.
|
||||
const onStart = () =>
|
||||
start.mutate(undefined, {
|
||||
onSuccess: refreshStatus,
|
||||
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_start_failed()),
|
||||
});
|
||||
const onStop = () =>
|
||||
stop.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
refreshStatus();
|
||||
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_stop_failed()),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import type { Capture } from "@/api/gen/model/capture";
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import { QueryState } from "@/components/query-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { LatencyChart, ThroughputChart } from "./charts";
|
||||
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
|
||||
import { ChartBlock } from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -27,9 +27,27 @@ export const LiveSection: FC = () => {
|
||||
return <LiveCard live={live} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many samples the live charts plot.
|
||||
*
|
||||
* The live endpoint returns the capture SO FAR, which grows without bound — a capture left running
|
||||
* over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail
|
||||
* is also the only part anyone watches live (the full series is what the saved recording is for),
|
||||
* so plot a bounded window and leave the rest to the detail view.
|
||||
*/
|
||||
const LIVE_WINDOW = 600;
|
||||
|
||||
/** Live graphs while a capture is armed: latency stack + throughput. */
|
||||
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||
const samples = live.data?.samples ?? [];
|
||||
const all = live.data?.samples;
|
||||
// Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so
|
||||
// an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart
|
||||
// rebuild either (the charts memoise on exactly this).
|
||||
const samples = useMemo(
|
||||
() =>
|
||||
all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []),
|
||||
[all],
|
||||
);
|
||||
// A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as
|
||||
// "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting".
|
||||
const error =
|
||||
@@ -58,6 +76,18 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
|
||||
<ChartBlock title={m.stats_throughput_title()}>
|
||||
<ThroughputChart samples={samples} />
|
||||
</ChartBlock>
|
||||
{/* Loss/recovery was only ever visible AFTER stopping and reopening the
|
||||
saved recording — which is backwards: dropped frames and FEC recovery
|
||||
are what you watch a live capture FOR. The `kind` note keeps the
|
||||
GameStream caveat (only `frames` is instrumented there). */}
|
||||
<ChartBlock title={m.stats_health_title()}>
|
||||
<HealthChart samples={samples} kind={live.data?.meta?.kind} />
|
||||
</ChartBlock>
|
||||
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.stats_live_window({ count: LIVE_WINDOW })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user