3 Commits

Author SHA1 Message Date
enricobuehler caa7a1c735 chore(release): bump workspace version to 0.7.1
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 16s
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 1m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 52s
ci / bench (push) Successful in 5m1s
android-screenshots / screenshots (push) Successful in 47s
ci / rust (push) Successful in 9m48s
windows-host / package (push) Successful in 7m10s
android / android (push) Successful in 3m53s
release / apple (push) Successful in 8m47s
decky / build-publish (push) Successful in 15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m14s
deb / build-publish (push) Successful in 2m59s
apple / screenshots (push) Successful in 5m42s
flatpak / build-publish (push) Successful in 4m11s
linux-client-screenshots / screenshots (push) Successful in 1m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m19s
docker / deploy-docs (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
web-screenshots / screenshots (push) Successful in 2m34s
Cut 0.7.1 to test the GTK + Decky polish batch (57ae00a) on-device: the
host-click / disconnect-chord / gray-screen-recovery / leak fixes on the
Linux client, the Deck launcher perf profile, and the Decky pin/pairing
fixes. The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; refresh the 9 workspace
entries in Cargo.lock to match (CI builds --locked). Canary derives from
the tag (scripts/ci/pf-version.sh), so cutting v0.7.1 auto-advances canary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:47:58 +00:00
enricobuehler 13dc7fc49f style(linux): rustfmt the keyframe-recovery throttle line
apple / swift (push) Successful in 1m9s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Wrap the `last_kf_req.is_none_or(...)` guard to satisfy `cargo fmt --all
--check` (CI Format step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:47:16 +00:00
enricobuehler 57ae00a9c8 fix(clients): GTK + Decky polish batch from live Deck/Windows testing
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
GTK Linux client:
- hosts/library: clicking a card was dead — the handler was on
  FlowBoxChild::activate (never emitted on click); bridge child-activated
  → child.activate() on the FlowBox (ui_hosts, ui_library).
- stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were
  dropped because the key controller sat on the overlay, which loses focus
  to the header back button after nav.push+fullscreen — move it to the
  window and remove it on teardown.
- video: a mid-session VAAPI decode error rebuilt a software decoder but
  never requested a keyframe, so under the infinite GOP the picture stayed
  gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware
  decoder, and demote to software only after repeated failures.
- stream: fix a per-session Capture↔overlay reference cycle that leaked the
  overlay subtree + the Arc<NativeClient> on every session end — hold the
  overlay weakly.
- stream: accumulate the fractional wheel remainder so precision-scroll
  (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped.
- gamepad library: keep the launcher smooth on the Deck — freeze the aurora
  and trim the visible card range (fewer 3D offscreen passes) on low-power.
- gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to
  diagnose an empty controller list on the Deck.
- cli: --connect host:<badport> silently did nothing; default to 9777 + warn.
- css: add the missing .pf-neutral pill rule; fix the clipped most-recent
  accent (inset outline instead of a corner-clipped box-shadow bar).

Decky plugin:
- surface the on-screen library browser: label the host-row Games button.
- fix silent pin data-loss — the detached Games modal captured a frozen
  pins array, so pinning a second game clobbered the first; mirror pins in
  a ref and track the modal's pinned ids locally for a live label.
- route pair-required hosts through the pairing modal from the fullscreen
  Stream button (parity with the QAM panel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:37:04 +00:00
14 changed files with 231 additions and 56 deletions
Generated
+9 -9
View File
@@ -2119,7 +2119,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.7.0" version = "0.7.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2251,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2875,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2889,7 +2889,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2911,7 +2911,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2934,7 +2934,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2964,7 +2964,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3034,7 +3034,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3048,7 +3048,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.7.0" version = "0.7.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+18 -7
View File
@@ -1,7 +1,7 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page. // Shared state hooks + user actions for the QAM panel and the fullscreen page.
import { toaster } from "@decky/api"; import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui"; import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
checkUpdate, checkUpdate,
discover, discover,
@@ -220,6 +220,14 @@ export interface PinsApi {
export function usePins(): PinsApi { export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]); 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 () => { const refresh = useCallback(async () => {
try { try {
@@ -236,6 +244,7 @@ export function usePins(): PinsApi {
// Optimistic local state; the backend validates/dedups and is re-read on failure. // Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback( const save = useCallback(
(next: PinnedGame[]) => { (next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next); setPins(next);
setPinsBackend(next).catch(() => void refresh()); setPinsBackend(next).catch(() => void refresh());
}, },
@@ -258,18 +267,20 @@ export function usePins(): PinsApi {
paired: h.paired, paired: h.paired,
}; };
save([ 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, pin,
]); ]);
}, },
[pins, save], [save],
); );
const removePin = useCallback( const removePin = useCallback(
(hostFp: string, gameId: string) => { (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( const isPinned = useCallback(
@@ -284,14 +295,14 @@ export function usePins(): PinsApi {
return; return;
} }
save( save(
pins.map((p) => pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id 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, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p, : p,
), ),
); );
}, },
[pins, save], [save],
); );
return { pins, addPin, removePin, isPinned, updatePinHost, refresh }; return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
+20 -8
View File
@@ -95,6 +95,24 @@ export const GamePickerModal: FC<{
}> = ({ host, pins, clientUpdatePending, closeModal }) => { }> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null); const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing) 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(() => { useEffect(() => {
let stale = false; let stale = false;
@@ -188,7 +206,7 @@ export const GamePickerModal: FC<{
{sorted.length > 0 && ( {sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}> <div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => { {sorted.map((g: GameEntry) => {
const pinned = pins.isPinned(host.fp, g.id); const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id); const safe = isSafeLaunchId(g.id);
return ( return (
<Field <Field
@@ -199,13 +217,7 @@ export const GamePickerModal: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
style={pickButton}
disabled={!safe}
onClick={() =>
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
}
>
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"} {pinned ? "Unpin" : "Pin"}
</DialogButton> </DialogButton>
+15 -3
View File
@@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
> >
<FaInfoCircle /> <FaInfoCircle />
</DialogButton> </DialogButton>
<DialogButton style={iconButton} onClick={onGames}> {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
<FaThLarge /> 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> </DialogButton>
{needsPair && ( {needsPair && (
<DialogButton <DialogButton
@@ -162,7 +165,16 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
Pair Pair
</DialogButton> </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" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Stream Stream
</DialogButton> </DialogButton>
+5 -1
View File
@@ -22,10 +22,14 @@ const CSS: &str = "
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); } 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-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-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; .pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
background: alpha(currentColor, 0.35); } background: alpha(currentColor, 0.35); }
.pf-pip.pf-online { background: @success_color; } .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-discovered { border: 1px dashed alpha(currentColor, 0.35); }
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } .pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } .pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
+8 -1
View File
@@ -94,10 +94,17 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
} }
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?; let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
let (addr, port) = parse_host_port(&target); 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 { Some(ConnectRequest {
name: addr.clone(), name: addr.clone(),
addr, addr,
port: port?, port,
fp_hex: None, fp_hex: None,
pair_optional: false, pair_optional: false,
launch: arg_value("--launch").map(|id| (id.clone(), id)), launch: arg_value("--launch").map(|id| (id.clone(), id)),
+10 -1
View File
@@ -910,7 +910,16 @@ impl Worker<'_> {
if !self.order.contains(&which) { if !self.order.contains(&which) {
self.order.push(which); self.order.push(which);
if let Some(p) = self.pad_info(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); self.refresh_active(active);
} }
+14
View File
@@ -331,6 +331,20 @@ fn pump(
// Survivable (loss until the next IDR/RFI recovery) — keep feeding. // Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"), 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::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
+19 -4
View File
@@ -93,6 +93,11 @@ struct State {
anim_active: Cell<bool>, anim_active: Cell<bool>,
last_tick: Cell<i64>, last_tick: Cell<i64>,
animations: bool, 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_title: gtk::Label,
detail_store: gtk::Label, detail_store: gtk::Label,
/// Transient error strip on the carousel scene (connect failures land here — the /// 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(&stack);
content.append(&hints); content.append(&hints);
let low_power = crate::gamepad::is_steam_deck();
let root = gtk::Overlay::new(); let root = gtk::Overlay::new();
root.add_css_class("pf-gl-page"); 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.add_overlay(&content);
root.set_focusable(true); 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), anim_active: Cell::new(false),
last_tick: Cell::new(0), last_tick: Cell::new(0),
animations: animations_enabled(), animations: animations_enabled(),
low_power,
detail_title, detail_title,
detail_store, detail_store,
status, status,
@@ -917,10 +926,14 @@ fn relayout(state: &State) {
} }
let pos = state.anim_pos.get(); let pos = state.anim_pos.get();
let bump = state.bump.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() { for (i, card) in state.cards.borrow().iter().enumerate() {
let d = i as f64 - pos; let d = i as f64 - pos;
let a = d.abs(); let a = d.abs();
if a > VISIBLE_RANGE { if a > range {
card.root.set_visible(false); card.root.set_visible(false);
continue; 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 /// 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 /// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
/// wasted heat on a couch device). /// wasted heat on a couch device).
fn build_aurora() -> gtk::DrawingArea { fn build_aurora(low_power: bool) -> gtk::DrawingArea {
let area = gtk::DrawingArea::new(); let area = gtk::DrawingArea::new();
area.set_hexpand(true); area.set_hexpand(true);
area.set_vexpand(true); area.set_vexpand(true);
@@ -1043,7 +1056,9 @@ fn build_aurora() -> gtk::DrawingArea {
let t = t.clone(); let t = t.clone();
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache)); 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 start = Cell::new(0i64);
let last = Cell::new(0i64); let last = Cell::new(0i64);
area.add_tick_callback(move |area, clock| { area.add_tick_callback(move |area, clock| {
+9
View File
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
let disc_heading = heading("On this network"); let disc_heading = heading("On this network");
let disc_flow = make_flow(); 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. // Shown under the discovered heading while no (unsaved) advert is live yet.
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8); let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let spinner = gtk::Spinner::new(); let spinner = gtk::Spinner::new();
+5
View File
@@ -71,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
.row_spacing(18) .row_spacing(18)
.valign(gtk::Align::Start) .valign(gtk::Align::Start)
.build(); .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); let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24); content.set_margin_top(24);
content.set_margin_bottom(24); content.set_margin_bottom(24);
+57 -16
View File
@@ -167,7 +167,13 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
struct Capture { struct Capture {
connector: Arc<NativeClient>, connector: Arc<NativeClient>,
window: adw::ApplicationWindow, 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, hint: gtk::Label,
inhibit_shortcuts: bool, inhibit_shortcuts: bool,
captured: Cell<bool>, captured: Cell<bool>,
@@ -181,13 +187,19 @@ struct Capture {
/// VKs / GameStream button ids currently held — flushed up on release. /// VKs / GameStream button ids currently held — flushed up on release.
held_keys: RefCell<HashSet<u8>>, held_keys: RefCell<HashSet<u8>>,
held_buttons: RefCell<HashSet<u32>>, held_buttons: RefCell<HashSet<u32>>,
/// 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 { impl Capture {
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read. /// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
fn flush_pending_motion(&self) { fn flush_pending_motion(&self) {
if let Some((x, y)) = self.pending_abs.take() { 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) { if self.captured.replace(true) {
return; return;
} }
self.overlay if let Some(overlay) = self.overlay.upgrade() {
.set_cursor(gdk::Cursor::from_name("none", None).as_ref()); overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
}
self.hint.set_visible(false); self.hint.set_visible(false);
if self.inhibit_shortcuts { if self.inhibit_shortcuts {
if let Some(tl) = self if let Some(tl) = self
@@ -213,7 +226,9 @@ impl Capture {
if !self.captured.replace(false) { if !self.captured.replace(false) {
return; return;
} }
self.overlay.set_cursor(None); if let Some(overlay) = self.overlay.upgrade() {
overlay.set_cursor(None);
}
self.hint.set_visible(true); self.hint.set_visible(true);
self.pending_abs.set(None); // never flush motion gathered while captured self.pending_abs.set(None); // never flush motion gathered while captured
if let Some(tl) = self if let Some(tl) = self
@@ -261,13 +276,14 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
let capture = Rc::new(Capture { let capture = Rc::new(Capture {
connector, connector,
window: window.clone(), window: window.clone(),
overlay: w.overlay.clone(), overlay: w.overlay.downgrade(),
hint: w.hint.clone(), hint: w.hint.clone(),
inhibit_shortcuts, inhibit_shortcuts,
captured: Cell::new(false), captured: Cell::new(false),
pending_abs: Cell::new(None), pending_abs: Cell::new(None),
held_keys: RefCell::new(HashSet::new()), held_keys: RefCell::new(HashSet::new()),
held_buttons: RefCell::new(HashSet::new()), held_buttons: RefCell::new(HashSet::new()),
scroll_acc: Cell::new((0.0, 0.0)),
}); });
let presented = Rc::new(PresentedStats::default()); let presented = Rc::new(PresentedStats::default());
@@ -279,7 +295,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
presented.clone(), presented.clone(),
hdr.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_mouse(&w.overlay, &capture);
attach_scroll(&w.overlay, &capture); attach_scroll(&w.overlay, &capture);
if !chromeless { if !chromeless {
@@ -293,6 +309,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
&window, &window,
&stop, &stop,
(w.fs_handler, active_handler), (w.fs_handler, active_handler),
key_controller,
escape_future, escape_future,
disconnect_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) /// 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 /// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
/// a VK on the wire while captured. /// 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( fn attach_keyboard(
overlay: &gtk::Overlay,
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
capture: &Rc<Capture>, capture: &Rc<Capture>,
stop: &Arc<AtomicBool>, stop: &Arc<AtomicBool>,
stats: &gtk::Label, stats: &gtk::Label,
) { ) -> gtk::EventControllerKey {
let key = gtk::EventControllerKey::new(); let key = gtk::EventControllerKey::new();
key.set_propagation_phase(gtk::PropagationPhase::Capture); key.set_propagation_phase(gtk::PropagationPhase::Capture);
let cap = capture.clone(); 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 /// Mouse: absolute motion + buttons — forwarded only while captured; the click that
@@ -787,7 +812,8 @@ fn attach_mouse(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
}); });
overlay.add_controller(motion); 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(); let cap = capture.clone();
overlay.add_tick_callback(move |_, _| { overlay.add_tick_callback(move |_, _| {
cap.flush_pending_motion(); cap.flush_pending_motion();
@@ -797,7 +823,9 @@ fn attach_mouse(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
let click = gtk::GestureClick::builder().button(0).build(); let click = gtk::GestureClick::builder().button(0).build();
let cap = capture.clone(); let cap = capture.clone();
click.connect_pressed(move |g, _n, x, y| { 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() { if !cap.captured.get() {
cap.engage(); // the engaging click is suppressed toward the host cap.engage(); // the engaging click is suppressed toward the host
return; return;
@@ -833,16 +861,22 @@ fn attach_scroll(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
} }
cap.flush_pending_motion(); // scroll happens at the latest cursor position 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 // The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
// positive = down. Smooth fractions survive — libei's discrete scroll is // positive = down. libei's discrete scroll is 120-based too. Accumulate the
// 120-based too. // fractional remainder so precision-scroll sub-unit deltas aren't lost.
let vy = (-dy * 120.0) as i32; 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 { if vy != 0 {
ay -= f64::from(vy);
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0); 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 { if vx != 0 {
ax -= f64::from(vx);
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0); send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
} }
cap.scroll_acc.set((ax, ay));
glib::Propagation::Stop glib::Propagation::Stop
}); });
overlay.add_controller(scroll); overlay.add_controller(scroll);
@@ -938,12 +972,14 @@ fn wire_teardown(
window: &adw::ApplicationWindow, window: &adw::ApplicationWindow,
stop: &Arc<AtomicBool>, stop: &Arc<AtomicBool>,
handlers: (glib::SignalHandlerId, glib::SignalHandlerId), handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
key_controller: gtk::EventControllerKey,
escape_future: glib::JoinHandle<()>, escape_future: glib::JoinHandle<()>,
disconnect_future: glib::JoinHandle<()>, disconnect_future: glib::JoinHandle<()>,
) { ) {
let window = window.clone(); let window = window.clone();
let stop_h = stop.clone(); let stop_h = stop.clone();
let handlers = RefCell::new(Some(handlers)); let handlers = RefCell::new(Some(handlers));
let key_controller = RefCell::new(Some(key_controller));
let escape_future = RefCell::new(Some(escape_future)); let escape_future = RefCell::new(Some(escape_future));
let disconnect_future = RefCell::new(Some(disconnect_future)); let disconnect_future = RefCell::new(Some(disconnect_future));
page.connect_hidden(move |_| { page.connect_hidden(move |_| {
@@ -952,6 +988,11 @@ fn wire_teardown(
window.disconnect(fs); window.disconnect(fs);
window.disconnect(active); 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() { if let Some(f) = escape_future.borrow_mut().take() {
f.abort(); f.abort();
} }
+41 -5
View File
@@ -136,8 +136,19 @@ pub struct Decoder {
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion /// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
/// rebuilds the software decoder for the SAME codec. /// rebuilds the software decoder for the SAME codec.
codec_id: ffmpeg::codec::Id, 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. /// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id { pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
match wire { match wire {
@@ -183,6 +194,8 @@ impl Decoder {
return Ok(Decoder { return Ok(Decoder {
backend: Backend::Vaapi(v), backend: Backend::Vaapi(v),
codec_id, codec_id,
vaapi_fails: 0,
want_keyframe: false,
}); });
} }
Err(e) => { Err(e) => {
@@ -196,20 +209,43 @@ impl Decoder {
Ok(Decoder { Ok(Decoder {
backend: Backend::Software(SoftwareDecoder::new(codec_id)?), backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
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 /// 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 /// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the /// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes. /// 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>> { pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
match &mut self.backend { match &mut self.backend {
Backend::Vaapi(v) => match v.decode(au) { Backend::Vaapi(v) => match v.decode(au) {
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)), Ok(f) => {
self.vaapi_fails = 0;
Ok(f.map(DecodedImage::Dmabuf))
}
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software"); self.vaapi_fails += 1;
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?); 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) Ok(None)
} }
}, },