Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac706ba839 | |||
| 94b5f48d0b | |||
| 139d032e55 | |||
| caa7a1c735 | |||
| 13dc7fc49f | |||
| 57ae00a9c8 |
Generated
+9
-9
@@ -2119,7 +2119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2251,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2875,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2889,7 +2889,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2911,7 +2911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2934,7 +2934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2964,7 +2964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3034,7 +3034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3048,7 +3048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
@@ -220,6 +220,14 @@ export interface PinsApi {
|
||||
|
||||
export function usePins(): PinsApi {
|
||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||
// callbacks keep a stable identity (deps free of `pins`).
|
||||
const pinsRef = useRef<PinnedGame[]>([]);
|
||||
pinsRef.current = pins;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -236,6 +244,7 @@ export function usePins(): PinsApi {
|
||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||
const save = useCallback(
|
||||
(next: PinnedGame[]) => {
|
||||
pinsRef.current = next;
|
||||
setPins(next);
|
||||
setPinsBackend(next).catch(() => void refresh());
|
||||
},
|
||||
@@ -258,18 +267,20 @@ export function usePins(): PinsApi {
|
||||
paired: h.paired,
|
||||
};
|
||||
save([
|
||||
...pins.filter((p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id)),
|
||||
...pinsRef.current.filter(
|
||||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||
),
|
||||
pin,
|
||||
]);
|
||||
},
|
||||
[pins, save],
|
||||
[save],
|
||||
);
|
||||
|
||||
const removePin = useCallback(
|
||||
(hostFp: string, gameId: string) => {
|
||||
save(pins.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
},
|
||||
[pins, save],
|
||||
[save],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
@@ -284,14 +295,14 @@ export function usePins(): PinsApi {
|
||||
return;
|
||||
}
|
||||
save(
|
||||
pins.map((p) =>
|
||||
pinsRef.current.map((p) =>
|
||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
},
|
||||
[pins, save],
|
||||
[save],
|
||||
);
|
||||
|
||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||
|
||||
@@ -95,6 +95,24 @@ export const GamePickerModal: FC<{
|
||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||
);
|
||||
const togglePin = (g: GameEntry) => {
|
||||
const wasPinned = pinnedIds.has(g.id);
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasPinned) next.delete(g.id);
|
||||
else next.add(g.id);
|
||||
return next;
|
||||
});
|
||||
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||
else pins.addPin(host, g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
@@ -188,7 +206,7 @@ export const GamePickerModal: FC<{
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||
{sorted.map((g: GameEntry) => {
|
||||
const pinned = pins.isPinned(host.fp, g.id);
|
||||
const pinned = pinnedIds.has(g.id);
|
||||
const safe = isSafeLaunchId(g.id);
|
||||
return (
|
||||
<Field
|
||||
@@ -199,13 +217,7 @@ export const GamePickerModal: FC<{
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
disabled={!safe}
|
||||
onClick={() =>
|
||||
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
|
||||
}
|
||||
>
|
||||
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
|
||||
@@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
<DialogButton style={iconButton} onClick={onGames}>
|
||||
<FaThLarge />
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
@@ -162,7 +165,16 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={actionButton} onClick={() => startStream(host)}>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
needsPair
|
||||
? showModal(
|
||||
<PairModal host={host} onPaired={() => startStream(host)} />,
|
||||
)
|
||||
: startStream(host)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
|
||||
@@ -22,10 +22,14 @@ const CSS: &str = "
|
||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
||||
.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); }
|
||||
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: alpha(currentColor, 0.35); }
|
||||
.pf-pip.pf-online { background: @success_color; }
|
||||
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
|
||||
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
|
||||
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||
the card's own elevation shadow intact. */
|
||||
.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; }
|
||||
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
|
||||
@@ -94,10 +94,17 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
}
|
||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
|
||||
// silently landed on the hosts page with no session and no message. Fall back to the
|
||||
// native default like the add-host dialog, and say so, instead of doing nothing.
|
||||
let port = port.unwrap_or_else(|| {
|
||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||
9777
|
||||
});
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port: port?,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||
|
||||
@@ -910,7 +910,16 @@ impl Worker<'_> {
|
||||
if !self.order.contains(&which) {
|
||||
self.order.push(which);
|
||||
if let Some(p) = self.pad_info(which) {
|
||||
tracing::info!(name = p.name, "gamepad attached");
|
||||
// Full identity: on a Steam Deck this is the one lever for diagnosing an
|
||||
// empty controller list — it tells you whether SDL sees the physical pad
|
||||
// (28DE:1205), Steam Input's virtual pad (28DE:11FF), both, or nothing.
|
||||
tracing::info!(
|
||||
name = p.name,
|
||||
key = p.key,
|
||||
pref = ?p.pref,
|
||||
steam_virtual = p.steam_virtual,
|
||||
"gamepad attached"
|
||||
);
|
||||
}
|
||||
self.refresh_active(active);
|
||||
}
|
||||
|
||||
@@ -331,6 +331,20 @@ fn pump(
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
}
|
||||
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
|
||||
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
|
||||
// gray/frozen until an unrelated packet drop happened to request one. Route it
|
||||
// through the same throttle as loss recovery below.
|
||||
if decoder.take_keyframe_request() {
|
||||
let now = Instant::now();
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!("requested keyframe (decoder recovery)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||
|
||||
@@ -93,6 +93,11 @@ struct State {
|
||||
anim_active: Cell<bool>,
|
||||
last_tick: Cell<i64>,
|
||||
animations: bool,
|
||||
/// Deck (or any low-power box): shrink the per-frame GPU work so navigation stays smooth
|
||||
/// — fewer laid-out cards (fewer 3D offscreen passes) and a frozen aurora (no 30 Hz
|
||||
/// full-screen CPU upscale + multi-MB texture upload contending for the iGPU's shared
|
||||
/// bandwidth). The Deck iGPU otherwise drops to ~16 fps mid-navigation.
|
||||
low_power: bool,
|
||||
detail_title: gtk::Label,
|
||||
detail_store: gtk::Label,
|
||||
/// Transient error strip on the carousel scene (connect failures land here — the
|
||||
@@ -300,9 +305,12 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
|
||||
content.append(&stack);
|
||||
content.append(&hints);
|
||||
|
||||
let low_power = crate::gamepad::is_steam_deck();
|
||||
let root = gtk::Overlay::new();
|
||||
root.add_css_class("pf-gl-page");
|
||||
root.set_child(Some(&build_aurora()));
|
||||
// On the Deck the animated aurora's per-frame CPU upscale + texture upload starves the
|
||||
// coverflow of iGPU bandwidth — freeze it (drift is centimeters/minute, unnoticeable).
|
||||
root.set_child(Some(&build_aurora(low_power)));
|
||||
root.add_overlay(&content);
|
||||
root.set_focusable(true);
|
||||
|
||||
@@ -330,6 +338,7 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
|
||||
anim_active: Cell::new(false),
|
||||
last_tick: Cell::new(0),
|
||||
animations: animations_enabled(),
|
||||
low_power,
|
||||
detail_title,
|
||||
detail_store,
|
||||
status,
|
||||
@@ -917,10 +926,14 @@ fn relayout(state: &State) {
|
||||
}
|
||||
let pos = state.anim_pos.get();
|
||||
let bump = state.bump.get();
|
||||
// Each laid-out side card is a non-affine (perspective + rotate_3d) transform, which GSK
|
||||
// renders through its own offscreen pass — so the visible count is the per-frame GPU cost.
|
||||
// Trim it hard on the Deck; desktop keeps the full deep shelf.
|
||||
let range = if state.low_power { 3.0 } else { VISIBLE_RANGE };
|
||||
for (i, card) in state.cards.borrow().iter().enumerate() {
|
||||
let d = i as f64 - pos;
|
||||
let a = d.abs();
|
||||
if a > VISIBLE_RANGE {
|
||||
if a > range {
|
||||
card.root.set_visible(false);
|
||||
continue;
|
||||
}
|
||||
@@ -1033,7 +1046,7 @@ fn animations_enabled() -> bool {
|
||||
/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the
|
||||
/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
|
||||
/// wasted heat on a couch device).
|
||||
fn build_aurora() -> gtk::DrawingArea {
|
||||
fn build_aurora(low_power: bool) -> gtk::DrawingArea {
|
||||
let area = gtk::DrawingArea::new();
|
||||
area.set_hexpand(true);
|
||||
area.set_vexpand(true);
|
||||
@@ -1043,7 +1056,9 @@ fn build_aurora() -> gtk::DrawingArea {
|
||||
let t = t.clone();
|
||||
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
|
||||
}
|
||||
if animations_enabled() {
|
||||
// Deck: render once, frozen — the 30 Hz tick's CPU upscale + texture upload is the
|
||||
// bandwidth cost that starves the coverflow. Desktop keeps the live drift.
|
||||
if animations_enabled() && !low_power {
|
||||
let start = Cell::new(0i64);
|
||||
let last = Cell::new(0i64);
|
||||
area.add_tick_callback(move |area, clock| {
|
||||
|
||||
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
||||
let disc_heading = heading("On this network");
|
||||
let disc_flow = make_flow();
|
||||
|
||||
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
||||
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
||||
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
||||
for flow in [&saved_flow, &disc_flow] {
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
}
|
||||
|
||||
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let spinner = gtk::Spinner::new();
|
||||
|
||||
@@ -71,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
|
||||
@@ -167,7 +167,13 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
|
||||
struct Capture {
|
||||
connector: Arc<NativeClient>,
|
||||
window: adw::ApplicationWindow,
|
||||
overlay: gtk::Overlay,
|
||||
/// Held WEAKLY. Every input controller + the frame-clock tick are added to this overlay
|
||||
/// and each captures `Rc<Capture>`; a strong ref back here would close the cycle
|
||||
/// `overlay → controller → Rc<Capture> → overlay` that GTK can't collect, leaking the
|
||||
/// whole stream subtree AND the `Arc<NativeClient>` (so `NativeClient::Drop` never runs)
|
||||
/// on every session end — unbounded growth across the reconnects a Deck does constantly.
|
||||
/// The live widget tree owns the overlay for the session's lifetime; upgrade at use.
|
||||
overlay: glib::WeakRef<gtk::Overlay>,
|
||||
hint: gtk::Label,
|
||||
inhibit_shortcuts: bool,
|
||||
captured: Cell<bool>,
|
||||
@@ -181,13 +187,19 @@ struct Capture {
|
||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||
held_keys: RefCell<HashSet<u8>>,
|
||||
held_buttons: RefCell<HashSet<u32>>,
|
||||
/// Fractional wheel remainder per axis (x, y), in 120-unit WHEEL_DELTA space. Precision
|
||||
/// scroll surfaces — the Deck trackpad, hi-res wheels, two-finger touchpad — deliver
|
||||
/// sub-unit deltas; truncating each event drops the tail. Carry it here instead.
|
||||
scroll_acc: Cell<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
||||
fn flush_pending_motion(&self) {
|
||||
if let Some((x, y)) = self.pending_abs.take() {
|
||||
send_abs(&self.overlay, &self.connector, x, y);
|
||||
if let Some(overlay) = self.overlay.upgrade() {
|
||||
send_abs(&overlay, &self.connector, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +207,9 @@ impl Capture {
|
||||
if self.captured.replace(true) {
|
||||
return;
|
||||
}
|
||||
self.overlay
|
||||
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
||||
if let Some(overlay) = self.overlay.upgrade() {
|
||||
overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
||||
}
|
||||
self.hint.set_visible(false);
|
||||
if self.inhibit_shortcuts {
|
||||
if let Some(tl) = self
|
||||
@@ -213,7 +226,9 @@ impl Capture {
|
||||
if !self.captured.replace(false) {
|
||||
return;
|
||||
}
|
||||
self.overlay.set_cursor(None);
|
||||
if let Some(overlay) = self.overlay.upgrade() {
|
||||
overlay.set_cursor(None);
|
||||
}
|
||||
self.hint.set_visible(true);
|
||||
self.pending_abs.set(None); // never flush motion gathered while captured
|
||||
if let Some(tl) = self
|
||||
@@ -261,13 +276,14 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
let capture = Rc::new(Capture {
|
||||
connector,
|
||||
window: window.clone(),
|
||||
overlay: w.overlay.clone(),
|
||||
overlay: w.overlay.downgrade(),
|
||||
hint: w.hint.clone(),
|
||||
inhibit_shortcuts,
|
||||
captured: Cell::new(false),
|
||||
pending_abs: Cell::new(None),
|
||||
held_keys: RefCell::new(HashSet::new()),
|
||||
held_buttons: RefCell::new(HashSet::new()),
|
||||
scroll_acc: Cell::new((0.0, 0.0)),
|
||||
});
|
||||
|
||||
let presented = Rc::new(PresentedStats::default());
|
||||
@@ -279,7 +295,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
presented.clone(),
|
||||
hdr.clone(),
|
||||
);
|
||||
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
|
||||
let key_controller = attach_keyboard(&window, &capture, &stop, &w.stats_label);
|
||||
attach_mouse(&w.overlay, &capture);
|
||||
attach_scroll(&w.overlay, &capture);
|
||||
if !chromeless {
|
||||
@@ -293,6 +309,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
&window,
|
||||
&stop,
|
||||
(w.fs_handler, active_handler),
|
||||
key_controller,
|
||||
escape_future,
|
||||
disconnect_future,
|
||||
);
|
||||
@@ -696,13 +713,20 @@ fn spawn_frame_consumer(
|
||||
/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D)
|
||||
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
|
||||
/// a VK on the wire while captured.
|
||||
///
|
||||
/// The controller lives on the **window**, not the stream overlay: a `NavigationView` push
|
||||
/// followed by `window.fullscreen()` hands keyboard focus to the pushed page's header back
|
||||
/// button (a sibling of the overlay), so an overlay-scoped key controller never sees a key and
|
||||
/// every chord — plus all gameplay key forwarding — is silently dropped until the user clicks
|
||||
/// the stream. The window is always on the key-propagation path regardless of which child holds
|
||||
/// focus. Returned so `wire_teardown` can remove it when the page goes away (otherwise the
|
||||
/// chords would keep firing app-wide against a dead session).
|
||||
fn attach_keyboard(
|
||||
overlay: >k::Overlay,
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
stats: >k::Label,
|
||||
) {
|
||||
) -> gtk::EventControllerKey {
|
||||
let key = gtk::EventControllerKey::new();
|
||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let cap = capture.clone();
|
||||
@@ -768,7 +792,8 @@ fn attach_keyboard(
|
||||
}
|
||||
}
|
||||
});
|
||||
overlay.add_controller(key);
|
||||
window.add_controller(key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
|
||||
@@ -787,7 +812,8 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
||||
});
|
||||
overlay.add_controller(motion);
|
||||
|
||||
// The per-tick flush. (The tick callback dies with the overlay, so no teardown.)
|
||||
// The per-tick flush. The tick callback dies with the overlay (which `Capture` now holds
|
||||
// only weakly, so it truly can), taking its `Capture` ref with it — no explicit teardown.
|
||||
let cap = capture.clone();
|
||||
overlay.add_tick_callback(move |_, _| {
|
||||
cap.flush_pending_motion();
|
||||
@@ -797,7 +823,9 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
||||
let click = gtk::GestureClick::builder().button(0).build();
|
||||
let cap = capture.clone();
|
||||
click.connect_pressed(move |g, _n, x, y| {
|
||||
cap.overlay.grab_focus();
|
||||
if let Some(overlay) = cap.overlay.upgrade() {
|
||||
overlay.grab_focus();
|
||||
}
|
||||
if !cap.captured.get() {
|
||||
cap.engage(); // the engaging click is suppressed toward the host
|
||||
return;
|
||||
@@ -833,16 +861,22 @@ fn attach_scroll(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
||||
}
|
||||
cap.flush_pending_motion(); // scroll happens at the latest cursor position
|
||||
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
||||
// 120-based too.
|
||||
let vy = (-dy * 120.0) as i32;
|
||||
// positive = down. libei's discrete scroll is 120-based too. Accumulate the
|
||||
// fractional remainder so precision-scroll sub-unit deltas aren't lost.
|
||||
let (mut ax, mut ay) = cap.scroll_acc.get();
|
||||
ay += -dy * 120.0;
|
||||
ax += dx * 120.0;
|
||||
let vy = ay.trunc() as i32;
|
||||
if vy != 0 {
|
||||
ay -= f64::from(vy);
|
||||
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||
}
|
||||
let vx = (dx * 120.0) as i32;
|
||||
let vx = ax.trunc() as i32;
|
||||
if vx != 0 {
|
||||
ax -= f64::from(vx);
|
||||
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||
}
|
||||
cap.scroll_acc.set((ax, ay));
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
overlay.add_controller(scroll);
|
||||
@@ -938,12 +972,14 @@ fn wire_teardown(
|
||||
window: &adw::ApplicationWindow,
|
||||
stop: &Arc<AtomicBool>,
|
||||
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
||||
key_controller: gtk::EventControllerKey,
|
||||
escape_future: glib::JoinHandle<()>,
|
||||
disconnect_future: glib::JoinHandle<()>,
|
||||
) {
|
||||
let window = window.clone();
|
||||
let stop_h = stop.clone();
|
||||
let handlers = RefCell::new(Some(handlers));
|
||||
let key_controller = RefCell::new(Some(key_controller));
|
||||
let escape_future = RefCell::new(Some(escape_future));
|
||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||
page.connect_hidden(move |_| {
|
||||
@@ -952,6 +988,11 @@ fn wire_teardown(
|
||||
window.disconnect(fs);
|
||||
window.disconnect(active);
|
||||
}
|
||||
// The key controller lives on the window (see `attach_keyboard`) — remove it so its
|
||||
// chords don't keep firing app-wide against a torn-down session.
|
||||
if let Some(kc) = key_controller.borrow_mut().take() {
|
||||
window.remove_controller(&kc);
|
||||
}
|
||||
if let Some(f) = escape_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
|
||||
@@ -136,8 +136,19 @@ pub struct Decoder {
|
||||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||||
/// rebuilds the software decoder for the SAME codec.
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
/// Consecutive VAAPI decode errors — a single transient failure (e.g. a reference-missing
|
||||
/// frame after packet loss) shouldn't cost the whole session its hardware decoder.
|
||||
vaapi_fails: u32,
|
||||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||||
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||||
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||||
want_keyframe: bool,
|
||||
}
|
||||
|
||||
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
|
||||
/// transient error just re-requests an IDR and keeps the hardware decoder.
|
||||
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
match wire {
|
||||
@@ -183,6 +194,8 @@ impl Decoder {
|
||||
return Ok(Decoder {
|
||||
backend: Backend::Vaapi(v),
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -196,20 +209,43 @@ impl Decoder {
|
||||
Ok(Decoder {
|
||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||
codec_id,
|
||||
vaapi_fails: 0,
|
||||
want_keyframe: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||||
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||||
pub fn take_keyframe_request(&mut self) -> bool {
|
||||
std::mem::take(&mut self.want_keyframe)
|
||||
}
|
||||
|
||||
/// Feed one access unit; returns the decoded frame (the host's streams are
|
||||
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
||||
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
|
||||
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
|
||||
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
|
||||
/// decoder; only a persistent streak of failures (a genuinely broken driver, e.g.
|
||||
/// nvidia-vaapi-driver) demotes to software. Either way `want_keyframe` is set so the
|
||||
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||||
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||
match &mut self.backend {
|
||||
Backend::Vaapi(v) => match v.decode(au) {
|
||||
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)),
|
||||
Ok(f) => {
|
||||
self.vaapi_fails = 0;
|
||||
Ok(f.map(DecodedImage::Dmabuf))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails += 1;
|
||||
self.want_keyframe = true;
|
||||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
"VAAPI decode failing repeatedly — demoting to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
} else {
|
||||
tracing::warn!(error = %e,
|
||||
"VAAPI decode error — requesting keyframe, keeping hardware decode");
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,6 +62,38 @@ systemctl reboot
|
||||
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
|
||||
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
|
||||
|
||||
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough
|
||||
|
||||
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
|
||||
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
|
||||
> sit frozen for months (a pinned `:stable` tag, a paused rebase), so `rpm-ostree upgrade` keeps
|
||||
> reporting *"No updates available"* and your layered `punktfunk` stays put even after new RPMs
|
||||
> land in the repo. (Diagnose: `rpm-ostree status` shows the base `Version:` unchanged, while
|
||||
> `dnf -q repoquery --upgrades punktfunk` lists newer builds.)
|
||||
|
||||
To actually pull a newer host on a static base, force rpm-ostree to re-resolve just the punktfunk
|
||||
layer — remove + re-add the same names in one transaction:
|
||||
|
||||
```sh
|
||||
sudo rpm-ostree refresh-md --force
|
||||
sudo rpm-ostree update \
|
||||
--uninstall punktfunk --uninstall punktfunk-web \
|
||||
--install punktfunk --install punktfunk-web
|
||||
systemctl reboot
|
||||
```
|
||||
|
||||
Or just run the helper, which detects what's layered and does the above:
|
||||
|
||||
```sh
|
||||
sudo bash packaging/bazzite/update-punktfunk.sh # stage; reboot when ready
|
||||
sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
|
||||
```
|
||||
|
||||
> **Channel gotcha:** the re-resolve picks the highest version across **every enabled**
|
||||
> `/etc/yum.repos.d/punktfunk*.repo`. If `punktfunk-canary.repo` is enabled alongside the stable
|
||||
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
|
||||
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
|
||||
|
||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||
|
||||
The image is built **off-host** (on any machine with `podman`) from
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update the layered punktfunk packages on a Bazzite / Fedora-Atomic host.
|
||||
#
|
||||
# Why this exists: `rpm-ostree upgrade` upgrades the *base image* and only re-resolves
|
||||
# layered packages WHEN THE BASE CHANGES. Bazzite bases can sit frozen for months (a pinned
|
||||
# `:stable` tag, a paused rebase), so `rpm-ostree upgrade` keeps reporting "No updates
|
||||
# available" and your layered punktfunk never moves even though newer RPMs are in the repo.
|
||||
# The fix is to force rpm-ostree to re-resolve just the punktfunk layer against the latest
|
||||
# repo metadata — an `--uninstall … --install …` of the same package names in one
|
||||
# transaction. This script does that for whichever of punktfunk / punktfunk-web are layered.
|
||||
#
|
||||
# Usage: sudo bash update-punktfunk.sh # stage the newest; you reboot when ready
|
||||
# sudo bash update-punktfunk.sh --reboot # stage, then reboot immediately
|
||||
#
|
||||
# Channel note: it re-resolves against every ENABLED punktfunk repo. If both
|
||||
# `punktfunk.repo` (stable) and `punktfunk-canary.repo` are enabled, canary's version sorts
|
||||
# higher and WINS — the box silently tracks canary. Enable exactly the channel you want
|
||||
# (set `enabled=0` in the other `/etc/yum.repos.d/punktfunk*.repo`).
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "run as root: sudo bash $0 ${*:-}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Which punktfunk packages are actually layered right now (host, web, or both).
|
||||
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
|
||||
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)
|
||||
if [[ ${#layered[@]} -eq 0 ]]; then
|
||||
# Fall back to the rpm db if the JSON shape ever changes.
|
||||
mapfile -t layered < <(rpm -qa --qf '%{NAME}\n' 'punktfunk' 'punktfunk-web' 2>/dev/null | sort -u)
|
||||
fi
|
||||
if [[ ${#layered[@]} -eq 0 ]]; then
|
||||
echo "no punktfunk packages are layered — install first (see packaging/bazzite/README.md)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "layered punktfunk packages: ${layered[*]}"
|
||||
|
||||
# Fresh repo metadata, else the re-resolve can pick a stale 'newest'.
|
||||
rpm-ostree refresh-md --force >/dev/null
|
||||
|
||||
# Force the re-resolve: remove + re-add the same names in ONE transaction so the box is never
|
||||
# left without the host, and rpm-ostree picks the newest available version.
|
||||
args=()
|
||||
for p in "${layered[@]}"; do args+=(--uninstall "$p"); done
|
||||
for p in "${layered[@]}"; do args+=(--install "$p"); done
|
||||
echo "+ rpm-ostree update ${args[*]}"
|
||||
rpm-ostree update "${args[@]}"
|
||||
|
||||
echo
|
||||
echo "Staged. The new version activates on the next boot."
|
||||
if [[ "${1:-}" == "--reboot" ]]; then
|
||||
echo "rebooting now…"
|
||||
systemctl reboot
|
||||
else
|
||||
echo "Reboot when ready: systemctl reboot"
|
||||
fi
|
||||
@@ -64,10 +64,17 @@ finish-args:
|
||||
# does not apply.
|
||||
- --device=all
|
||||
- --filesystem=/run/udev:ro # SDL/HIDAPI enumerates devices via udev
|
||||
# --- audio: PipeWire via its PulseAudio shim — covers playback AND mic uplink. SteamOS
|
||||
# exposes PipeWire-pulse here; --socket=pulseaudio is the portable arg Moonlight/chiaki
|
||||
# also use on the Deck (a bare --socket=pipewire would also need the camera/portal dance
|
||||
# for capture; the pulse shim gives mic + speaker in one grant). ---
|
||||
# --- audio: the client speaks the NATIVE PipeWire protocol (audio.rs `pw connect`), NOT the
|
||||
# PulseAudio shim — so it needs the real `pipewire-0` socket in the sandbox. With only
|
||||
# --socket=pulseaudio the sandbox has just `pulse/native`, no `pipewire-0`, and playback +
|
||||
# mic both die with "pw connect (is PipeWire running in this session?)" (observed live on the
|
||||
# Deck in Gaming Mode). --socket=pipewire is the canonical grant; --filesystem=xdg-run/
|
||||
# pipewire-0 binds the same socket portably (validated on-Deck: it makes pipewire-0 appear in
|
||||
# the sandbox where --socket=pipewire's CLI validation was flaky). Neither needs the
|
||||
# camera/portal dance (that's only for camera nodes). --socket=pulseaudio stays as a fallback
|
||||
# for any pulse-only path. ---
|
||||
- --socket=pipewire
|
||||
- --filesystem=xdg-run/pipewire-0
|
||||
- --socket=pulseaudio
|
||||
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---
|
||||
- --share=network
|
||||
|
||||
Reference in New Issue
Block a user