Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1511374959 | ||
|
|
1198522931 | ||
|
|
79982060c3 | ||
|
|
fc6060f274 | ||
|
|
4399664217 | ||
|
|
e684b3e4bd | ||
|
|
8c4b1b8c62 | ||
|
|
b64ac3cb32 | ||
|
|
f92b093f92 | ||
|
|
b9adcc4897 | ||
|
|
d2c6e1c9c0 | ||
|
|
1d755ebeeb | ||
|
|
c407f6a6d9 |
+410
@@ -12,6 +12,416 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.4
|
||||
|
||||
21 commits since v0.31.3 (14 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**Nothing versioned moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25** —
|
||||
`include/punktfunk_core.h` is **byte-identical to the v0.31.3 tag, and to v0.31.2 and v0.31.1
|
||||
before it**, the third release running with no diff at all — and so do the driver protocol, the
|
||||
gamepad channel, the plugin index schema, the host event schema and the gamescope patch level.
|
||||
`pf-driver-proto` shows no diff. `api/openapi.json` is **content-identical**: only `info.version` is
|
||||
re-stamped, so this is the first release since v0.31.0 where the management API grows nothing.
|
||||
`@punktfunk/host` stays **0.1.6** and `@punktfunk/plugin-kit` stays **0.4.4**; nothing under `sdk/`
|
||||
or `plugin-kit/` moved. No dependency moves in any of the five Rust lockfiles.
|
||||
|
||||
The cycle is **Linux-host shaped**, and the faults share a premise: *a policy that was written
|
||||
against one desktop and silently did nothing on the others.* `Topology::Exclusive` on a gamescope
|
||||
session was implemented as `org_kde_kwin_dpms` and reached only a live KDE desktop, so a Game Mode
|
||||
box — the deployment whose TV the operator most wants dark — got nothing (#389, five commits:
|
||||
a DRM floor for a box with no desktop at all, sway and Hyprland arms, the managed route's own
|
||||
hold, and the box-session free that was gated on Steam). The `sleep:idle` block inhibitor refused
|
||||
every `Suspend()` for the length of a stream, including the operator's own from Steam's power menu,
|
||||
and on a display-manager-stopping takeover polkit's `allow_active` fallback found no session to
|
||||
elect and dropped all three power actions to `auth_admin_keep` (#391). The web console served the
|
||||
**legacy** identity because every launcher names that pair and none of them *can* choose, so
|
||||
browsers rejected it outright and the tray's mgmt-pinned probe refused the handshake and labelled a
|
||||
healthy console dead (#393). Alongside: the client's encode down-driver, whose thresholds were
|
||||
absolute durations calibrated at 120 Hz and whose stand-down was permanent (#392); an
|
||||
`installTap` format snapshot that could reach the Objective-C terminate handler (#387); the
|
||||
console's bitrate row (#388); and a GTK entry point for the console UI (#390).
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.31.3 | v0.31.4 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped |
|
||||
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.3 tag — nor against v0.31.2 or v0.31.1. Third release running |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged). `drm_dpms.rs` is a new **module** inside `pf-vdisplay`, not a new crate |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.3 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged. Nothing under the Windows gamepad backends moved |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs` shows no diff) |
|
||||
| `api/openapi.json` | 0.31.3 | **0.31.4** | **content-identical** — the `info.version` stamp is the whole diff. No route, schema or field added, removed or re-shaped. Re-stamped, not regenerated (`punktfunk-host` does not build on macOS); `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8` |
|
||||
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged; nothing under `sdk/` moved, and the API it wraps did not either |
|
||||
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, and
|
||||
this time not even an additive API change. Every 0.31.x host, client, driver and plugin keeps
|
||||
interoperating in both directions with no re-pairing and no rebuild.
|
||||
|
||||
One **source-level rename**, internal to `pf-vdisplay` and visible to nobody outside it:
|
||||
`kwin_dpms` → `panel_dpms`. It is no longer a KWin module. `org_kde_kwin_dpms` itself — the
|
||||
vendored protocol's interface name, which is not ours to rename — is untouched.
|
||||
|
||||
Five **behaviour** changes that break no build but change what a machine does:
|
||||
|
||||
- **The `sleep:idle` inhibitor is no longer held for the whole stream.** It is held only while the
|
||||
stream is quiet, dropped synchronously on any client input on either plane, and re-taken after
|
||||
`QUIET_BEFORE_VETO` (30 s) of silence. A host that previously vetoed every `Suspend()` for a
|
||||
session's length now vetoes only the ones that arrive during genuine inactivity.
|
||||
- **`Topology::Exclusive` now darkens panels on sway, Hyprland and a desktop-less box**, where it
|
||||
previously did nothing. If you run a gamescope session on one of those and had selected
|
||||
`exclusive` expecting the old no-op, you now get dark panels. `extend`, `primary` and the
|
||||
`SharedDesktop` preset are unchanged and still take no hold at all.
|
||||
- **`stop_autologin_sessions` is now reached for a non-Steam library launch** when the topology is
|
||||
exclusive. Previously only `is_steam_launch` reached it. Best-effort: it warns and continues,
|
||||
unlike the Steam arm above it, because there the cost is a dark screen rather than the game.
|
||||
- **The encode-attributed backoff re-arms after standing down**, on a doubling interval, rather
|
||||
than being disarmed for the rest of the session. Its SEVERE/MILD thresholds are now sized from
|
||||
the negotiated refresh (a fraction of the session's frame budget) instead of the absolute
|
||||
durations calibrated at 120 Hz, so a 60 Hz session no longer takes an immediate ×0.7 on an
|
||||
ordinary one-frame hiccup.
|
||||
- **The web console prefers the native identity pair.** When `PUNKTFUNK_UI_TLS_CERT` names
|
||||
`cert.pem` and `native-cert.pem`/`native-key.pem` both exist beside it, the console serves the
|
||||
native pair. A host with no native pair on disk, or an operator-supplied cert under any other
|
||||
name, falls through unchanged. `serve` now resolves the native identity **before** minting the
|
||||
legacy one.
|
||||
|
||||
### `Topology::Exclusive` becomes a per-desktop dispatcher
|
||||
|
||||
`kwin_dpms` asked KWin for DPMS-off, which is the right answer whenever there is a KDE desktop to
|
||||
ask. A gamescope session owns no output on the box's own desktop, so the desktop backends' `disable`
|
||||
route is wrong here — disabling would move the operator's workspaces and re-home their windows for a
|
||||
stream that is not even on their compositor, and on Hyprland a disabled head has no clean undo (the
|
||||
only known restore is `hyprctl reload`, which drops every runtime override). DPMS is a separate axis
|
||||
and round-trips cleanly. The module is renamed `panel_dpms` and becomes a dispatcher, each arm
|
||||
self-gating on its own IPC so a box only pays for the one that answers:
|
||||
|
||||
| desktop | mechanism |
|
||||
|---|---|
|
||||
| KDE / KWin | in-process `org_kde_kwin_dpms`, then `kscreen-doctor` |
|
||||
| sway (wlroots) | `swaymsg output <name> dpms off` |
|
||||
| Hyprland | `hyprctl dispatch dpms off <name>`, then the Lua `hl.dsp.dpms(...)` spelling |
|
||||
| none at all | `drm_dpms` — the CRTCs off over DRM |
|
||||
| GNOME / Mutter | **cannot be served** |
|
||||
|
||||
Both compositor arms reuse their backend's existing `heads_to_disable` filter with an **empty**
|
||||
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots session's `HEADLESS-*`
|
||||
still must be spared or darkening blacks out that client's stream. They address heads by name and
|
||||
report which ones actually changed, so the re-light undoes exactly those.
|
||||
|
||||
**GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to clients; its own
|
||||
`exclusive` mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its
|
||||
own to keep enabled, which a gamescope session does not have; and the DRM floor is refused because
|
||||
Mutter holds master. The decline was at `debug!` and is now a `warn!` naming the reason — every one
|
||||
of these means "you asked for your screens off and they stayed on".
|
||||
|
||||
**Not verified on glass: the sway arm.** The lab has Bazzite, Nobara and SteamOS — no sway box. Its
|
||||
argv shape and the sibling-sparing filter are unit-tested and it compiles clean, but the command has
|
||||
not been run against a live sway session. Hyprland, KDE and DRM were all measured on real machines.
|
||||
|
||||
### `drm_dpms`: the CRTCs off over DRM, for a box with no desktop to ask
|
||||
|
||||
Measured on the Nobara VM (123, Fedora 44) before any of it was written, because the cheaper answers
|
||||
all fail on real hardware: getting the box's session out of the way is **not** enough (with no
|
||||
client holding DRM master the kernel keeps the CRTC configured — `enabled=enabled dpms=On`
|
||||
indefinitely); `setterm --blank force` is unreachable (`/dev/tty1` is `crw------- root:tty`); the
|
||||
connector's `dpms` sysfs attribute is read-only even as root; and `/sys/class/graphics/fb0/blank`
|
||||
works but needs root, which the takeover deliberately stopped needing in 0.31.0.
|
||||
|
||||
What works unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for whoever holds the active
|
||||
seat (`crw-rw----+`), and `SET_MASTER` succeeds while nobody else is master — exactly the state the
|
||||
takeover has just produced. Walk `GETRESOURCES`, `GETCRTC` each id, `SETCRTC` with `fb_id=0,
|
||||
mode_valid=0, count_connectors=0` on the ones actually driving something. The connector reaches
|
||||
`enabled=disabled dpms=Off`, the same end state `kscreen-doctor --dpms off` reaches through KWin.
|
||||
|
||||
Two measured properties shrank the code. The darkness **survives `DROP_MASTER`**, so mastership is
|
||||
handed straight back — a host still holding it would starve the box's own gamescope when the restore
|
||||
relaunches its session. And the re-light is **`close(fd)`**: the kernel's last-close restores the
|
||||
console, so there is no saved mode to replay, no restore that can half-fail, and crash safety is
|
||||
free. Nothing to journal, nothing to sweep at startup. `SET_MASTER` failing is also the natural
|
||||
guard for the Attach route — a card already mastered by a live compositor is not ours, and darkening
|
||||
it would darken the very picture being streamed.
|
||||
|
||||
Four ioctls, three `#[repr(C)]` structs, one `unsafe` block. The ioctl numbers encode their payload
|
||||
size (`0x40`, `0x68`), so the layouts are pinned by `const _: () = assert!(...)` at compile time,
|
||||
plus a test that restates it greppably and pins the two field offsets the count/data-pass dance
|
||||
depends on.
|
||||
|
||||
### The managed gamescope route takes the hold too
|
||||
|
||||
Managed reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for anything not
|
||||
`Owned` **above** its `take_topology_restore()` pickup — so a hold registered the way the bare spawn
|
||||
does would never be released, and a panel dark after every stream is worse than one left lit. That
|
||||
is an argument for putting the release somewhere else, not for skipping the feature on the route
|
||||
that matters most (it is the recommended one, and the only way to serve a client its own virtual
|
||||
output at its own mode).
|
||||
|
||||
Somewhere else is `do_restore_tv_session`, the one teardown every managed path funnels through, at
|
||||
the very **top** of it because the SteamOS branch returns above the existing drop-in sweep.
|
||||
|
||||
The hold is a **bool, not a count**, and that is load-bearing: the managed *session* is what gets
|
||||
darkened, not each connect. It outlives client disconnects and a same-mode reconnect reuses it warm,
|
||||
so acquiring per connect would ratchet the refcount up with no matching releases and pin the
|
||||
operator's panel dark for the rest of the host's life. The edges are split into pure
|
||||
`managed_darken_{acquire,release}_edge` so the balance rule is unit-tested without a live
|
||||
compositor: reconnect takes no second hold, the unconditional release is idempotent, a full cycle
|
||||
re-arms, and `extend` / `SharedDesktop` still take no hold at all.
|
||||
|
||||
### The box's own session is freed for `exclusive`, not just for Steam
|
||||
|
||||
`stop_autologin_sessions` was reached only via `is_steam_launch`
|
||||
(`cmd.split_whitespace().next() == Some("steam")`), because freeing Steam's single instance was the
|
||||
only requirement anyone had for it. But on a Game Mode box that same session is the DRM master of
|
||||
the TV, so a **non-Steam** library game left it running and the panel showed live Game Mode for the
|
||||
whole stream — the 2026-08-24 Nobara field report. `exclusive` is now stated as a second,
|
||||
independent reason: `free_box_session_for_exclusive` is pure and unit-tested over all four
|
||||
Steam × Exclusive combinations, including that `extend` and the `SharedDesktop` preset must still
|
||||
leave the box strictly alone.
|
||||
|
||||
A related comment had rotted: the one authorising the DPMS skip on this file's other routes ("its
|
||||
takeover already stopped the desktop") outlived the stop it named by three commits — `c2f5e91b`
|
||||
replaced the managed takeover's display-manager STOP with an idle drop-in — so the gap read as
|
||||
intentional. It is replaced by the two reasons that actually hold, written at the returns they
|
||||
belong to.
|
||||
|
||||
### Hyprland: the dpms dispatcher is a toggle, and the classic argv does not parse under Lua
|
||||
|
||||
Verified on the NixOS VM (125, Hyprland 0.55.4), and both halves of the arm as first shipped were
|
||||
wrong — which is why it went on glass.
|
||||
|
||||
`hyprctl dispatch dpms off <name>` **dies on the Lua config manager**: `dispatch` is shorthand for
|
||||
`hl.dispatch(...)`, so the bare words are parsed as a Lua expression —
|
||||
|
||||
error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
|
||||
')' expected near 'off'
|
||||
|
||||
The Lua spelling is `hl.dsp.dpms("off", "<name>")`. A hyprlang box wants the classic form, there is
|
||||
no stable probe for which manager is loaded, and `hyprctl_dispatch` already catches the exit-0
|
||||
rejections both produce — so: try classic, then Lua, and report both failures if neither lands.
|
||||
|
||||
And the dispatcher is a **toggle that ignores the state word**. Measured, both spellings, positional
|
||||
and table:
|
||||
|
||||
On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked ON, got OFF
|
||||
Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
|
||||
Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked OFF, got ON
|
||||
|
||||
So a blind "send off, later send on" would **light** an already-dark head at stream start and
|
||||
**darken** a lit one at teardown — the operator's screen left off after the stream, precisely the
|
||||
failure this policy exists to prevent. `dpms_one` is therefore read → act only if the state differs
|
||||
→ verify, via `hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's sysfs
|
||||
`dpms` exactly in both states; a dark monitor stays listed). That shape is also correct where the
|
||||
call really is a set, so it is not conditional on detecting the manager. It returns whether it
|
||||
**changed** anything, and `dpms_other_heads` records only those.
|
||||
|
||||
### The sleep inhibitor is held only while the stream is quiet
|
||||
|
||||
Since `b7a00137` the host held a logind `sleep:idle` inhibitor in **BLOCK** mode for the whole
|
||||
length of a stream, so a passive video-only viewer could not have the box suspend out from under
|
||||
them. A block lock on `sleep` refuses **every** suspend, though: "Sleep" in Steam's Big Picture
|
||||
power menu reaches logind as the same `Suspend()` call and comes back as
|
||||
|
||||
Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".
|
||||
|
||||
Nothing in that UI surfaces a D-Bus error, so the entry simply did nothing for as long as anyone was
|
||||
connected. Reproduced on a Bazzite host, 2026-08-24, by taking the same lock by hand.
|
||||
|
||||
The veto is now held only while the stream is quiet. Any client input on either plane drops it
|
||||
**synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep press
|
||||
cannot race it — and it is re-taken after 30 s of silence. That is the line the original
|
||||
justification already drew: a person choosing Sleep is by definition sending input, and a passive
|
||||
viewer never does. Both planes stamp the clock at their single input entry point: the native input
|
||||
thread's channel `recv` (**before** the grant tests — a denied event still means a person is there)
|
||||
and the GameStream control plane's three decode arms, **past** the keepalive gate, because a
|
||||
keepalive is the one thing a passive viewer does send.
|
||||
|
||||
### polkit: three power actions for a session-less takeover
|
||||
|
||||
The other half of "the power menu does nothing during a stream", and an independent fault. logind
|
||||
ships `power-off`/`reboot`/`suspend` as `allow_active: yes`. polkit decides "active" from the
|
||||
caller's own logind session and, for a caller that has none — every `systemd --user` unit, which is
|
||||
exactly what the managed gamescope session is — falls back to the user's elected DISPLAY session.
|
||||
logind elects that only from `user`/`greeter` class sessions, never from the user manager's own, so
|
||||
a takeover that **stops** the display manager removes the last candidate and all three actions drop
|
||||
to `auth_admin_keep`: an interactive password prompt, put to a non-interactive caller, on a screen
|
||||
that is switched off.
|
||||
|
||||
On SteamOS-like boxes that is the call being refused. Steam does not ask logind for "Shut Down" at
|
||||
all — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus` runs a
|
||||
plain `poweroff` once Steam is gone. During a stream that wrapper is ours, in the session-less
|
||||
transient unit.
|
||||
|
||||
Measured on Bazzite, 2026-08-24: the identical
|
||||
`pkcheck --action-id org.freedesktop.login1.power-off` from a `systemd --user` unit answers
|
||||
authorized with sddm up, `auth_admin_keep` with sddm stopped, and authorized again in that same
|
||||
stopped state with this rule installed.
|
||||
|
||||
`packaging/linux/49-punktfunk-power.rules`, installed by the deb, rpm and arch packagings, scoped to
|
||||
the (shipped-empty) `punktfunk` group — the same group the takeover's own root helper
|
||||
(`io.unom.punktfunk.dm-helper`) authorizes on, so it grants to exactly the population the fault
|
||||
reaches. The `-multiple-sessions` and `-ignore-inhibit` variants are deliberately **not** granted.
|
||||
|
||||
### The encode down-driver: frame-budget thresholds and a re-armable stand-down
|
||||
|
||||
A 1440p60 Lutris session ratcheted **57 → 5 Mbps over ten minutes** on a link that never dropped a
|
||||
packet — no keyframe asks, no flushes, a flat decoder, and the host granting every rate it was
|
||||
asked for. Two causes, both client-side.
|
||||
|
||||
**The thresholds were absolute durations calibrated at 120 Hz** ("~half a frame budget",
|
||||
"≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup
|
||||
cleared the SEVERE tier and took the immediate ×0.7, where the same hiccup at 120 Hz (8.3 ms) never
|
||||
reached it — the exact asymmetry the field log shows against 1440p120 sessions on the same host and
|
||||
client. They are now sized in the session's own frame budget, plumbed from the negotiated refresh
|
||||
and re-read on a mode switch.
|
||||
|
||||
**And the signal fired for a cause the rate cannot fix.** It exists to find the encoder's compute
|
||||
knee, where cutting the rate cuts the work; when encode time is held up by a game saturating the
|
||||
GPU that premise is false, the backoff changes nothing, and `on_ack`'s baseline re-seed erases the
|
||||
evidence that nothing improved. Each encode-attributed backoff now remembers the level it fired at:
|
||||
two in a row that fire no lower mean the rate is not the lever, and the signal stands down. Same
|
||||
shape as `NOOP_CLOCK_FLUSHES_TO_DISARM`. Loss, OWD, decode and keyframe signals keep their full
|
||||
power throughout, and the host's own climb refusal stays the backstop for a genuine knee.
|
||||
|
||||
**The stand-down re-probes rather than lasting the session.** Nothing else this controller learns
|
||||
from evidence is permanent — both learned caps re-probe on the `CAP_REPROBE_WINDOWS_MIN` ladder, and
|
||||
the clock-flush detector was itself changed from "off for the rest of the session" to re-armable for
|
||||
exactly this reason. What the stand-down answers is transient by nature (contention ends when the
|
||||
game exits to a menu, when the shader storm finishes, when the second app on the card closes), and
|
||||
what it silences is the only signal that can descend when the encoder is genuinely past its knee on
|
||||
a link that shows nothing else. A clean run re-arms it, and the interval doubles each time the
|
||||
silence is immediately re-earned, so standing contention settles into a slow poll rather than
|
||||
thrashing. The asymmetry is the argument: a too-eager re-arm costs one ×0.7 and stands down again; a
|
||||
too-permanent silence costs the protection outright.
|
||||
|
||||
### The web console serves the native identity; the tray's probe drops its pin
|
||||
|
||||
The host has kept two identities since the identity split (`crate::identity`): `native-cert.pem` /
|
||||
`native-key.pem` (P-256, real SANs — what the native QUIC plane, the mgmt API and every native
|
||||
client pin) and the legacy `cert.pem` / `key.pem` (RSA, `CN=punktfunk`, **no SAN**, kept byte-stable
|
||||
for Moonlight). The web console never followed the split. Every launcher — the systemd unit, the
|
||||
NixOS module, the Windows service supervisor, `web-run.cmd`, the Steam Deck installer — names the
|
||||
**legacy** pair, and none of them *can* choose: `Environment=` has no "this file, else that one". So
|
||||
the console served a certificate with no SAN at all, which costs twice over: browsers reject a
|
||||
CN-only cert outright (`ERR_CERT_COMMON_NAME_INVALID`), and the tray's loopback liveness probe
|
||||
reused the agent **pinned** to the mgmt identity, so rustls refused the handshake and a healthy
|
||||
console was labelled "not responding" — next to a tooltip reading "idle", which is the proof, since
|
||||
the same agent reached mgmt fine on the very same tick.
|
||||
|
||||
The entry (`web/nitro-entry/tls-paths.mjs`) is the one place every launcher routes through, so the
|
||||
choice is made there: prefer the native sibling pair when both files exist, **as a pair or not at
|
||||
all** (a native cert with the legacy key completes no handshake with anyone). It is a **suffix
|
||||
test**, not `node:path`: `node:path` resolves per-runtime, so a POSIX CI runner reads
|
||||
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where
|
||||
`windows/service.rs` hands us exactly that, is the platform the CI job can never exercise. The
|
||||
suffix test gives the same answer everywhere, leaves the prefix **verbatim** (where
|
||||
`join(dirname(p), …)` normalised `/a/b/../cert.pem` into a different directory the moment `b` was a
|
||||
symlink), and cannot hand back a mismatched pair. Existence is not enough:
|
||||
`pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console
|
||||
starting mid-write could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart (the
|
||||
Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead) — the
|
||||
check is a **non-empty** stat, mirroring the host's own `!c.trim().is_empty()`.
|
||||
|
||||
`serve` now resolves the native identity **before** minting the legacy one. That closes a first-run
|
||||
window where the console (which waits on `cert.pem`) could start between the two writes and serve
|
||||
the SAN-less cert for the rest of the boot, and a second latent fault: with `cert.pem` missing but
|
||||
native clients paired, the old order let `load_or_create` mint a brand-new `cert.pem` that
|
||||
`load_or_adopt` then adopted while logging that it was preserving their pins.
|
||||
|
||||
The tray's console probe **loses** its pin rather than gaining a second one. It is a different
|
||||
server and there is no rule that it presents the mgmt certificate — an operator fronting the console
|
||||
with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials,
|
||||
reads no body, and decides only a menu label (plus whether a tray-icon single click opens the
|
||||
console, which `win.rs` gates on `console_up`). On Windows it was never pinned to begin with:
|
||||
`punktfunk_config_dir` is `None` off Linux, so `load_pin` already returned `None`.
|
||||
|
||||
`web/`'s test scope grows from `server/` to `server/ nitro-entry/`; 18 tests, including a win32
|
||||
case.
|
||||
|
||||
### Apple: the mic tap installs with `format: nil` and follows the device
|
||||
|
||||
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the bus and raises an
|
||||
Objective-C exception on **any** mismatch. Swift cannot catch that, so it reached the terminate
|
||||
handler and aborted the process — SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`, crashing macOS
|
||||
0.31.0 at **session start**, not at launch.
|
||||
|
||||
The format handed to the tap is necessarily read a moment earlier
|
||||
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath it in that window: a
|
||||
device switch, a clock/rate change, or the `kAudioOutputUnitProperty_CurrentDevice` swap
|
||||
`startCapture` itself performs two lines before. The existing guard only rejected the
|
||||
0 Hz / 0-channel case, which is a different failure — a device that is absent, not one that changed.
|
||||
|
||||
Installing with `format: nil` is the documented "use the bus's own format" and makes the mismatch
|
||||
unrepresentable rather than merely unlikely. The tap then has to follow the real format, so the
|
||||
rate-dependent pieces (mono bus, resampler, both scratch buffers) move into a `MicChain` the tap
|
||||
rebuilds when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would resample by
|
||||
the wrong ratio and pitch-shift the mic. That rebuild subsumes the old grow-on-larger-quantum
|
||||
branch; the steady state still allocates nothing. Tests cover the sizing arithmetic, including that
|
||||
`staging` fits the **upward** ratio, which silently truncates every packet on any device below
|
||||
48 kHz if it is sized for the input rate instead.
|
||||
|
||||
### The console's bitrate row: 30 rungs, a typed rate, and neighbour stepping
|
||||
|
||||
The gamepad shell's Bitrate picker had been seven rungs ending at **80 Mbps** since the console
|
||||
shipped — a ceiling a user ran into — while the GTK dialog beside it has always gone to
|
||||
3000 Mbit/s, so the two surfaces disagreed about what the machine may ask for and the console was
|
||||
the smaller. Three changes, one row:
|
||||
|
||||
- **30 rungs, 1 Mbps to 2 Gbps.** Tight at the bottom (1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25 …)
|
||||
where one rung decides whether a thin link is watchable, coarse at the top where a rung is noise.
|
||||
Rates at or above a gigabit read as Gbps.
|
||||
- **Y opens a typed rate on that row** — four digits, through the tray keyboard (or SDL text input,
|
||||
and Steam's own keyboard on a Deck) exactly like the add-host and pair fields. **A** goes on
|
||||
cycling the ladder everywhere, so the console's grammar is unchanged.
|
||||
- **A rate that is not a rung steps to its neighbour.** The generic picker snaps an unrecognised
|
||||
value to its first option, which on this row is Automatic: one nudge threw away a rate typed here
|
||||
or set by the desktop spinner.
|
||||
|
||||
`Screen::edit_key` now takes the context, because this is the first field that commits into the
|
||||
settings store when it closes rather than holding text for a later action row. The desktop dialog
|
||||
gets the same complaint's other half: its spinner steps 1 Mbit/s instead of 5, so 3, 4 and 6 are
|
||||
reachable without typing.
|
||||
|
||||
### Linux client: a front door to the console UI
|
||||
|
||||
The gamepad console has shipped since the arch split, but on Linux the only way in was
|
||||
`punktfunk-session --browse` (or `punktfunk-client --browse`, which execs it) on a command line. The
|
||||
WinUI shell has had both doors for a while; this is the GTK half. Three entry points, one
|
||||
destination: a gamepad button in the hosts header (`input-gaming-symbolic`, left of the hamburger —
|
||||
the placement WinUI gives it); a "Console UI" main-menu entry so the mode has a searchable name;
|
||||
and `io.unom.Punktfunk.Console.desktop`, a second launcher shipped by the deb, rpm, arch and flatpak
|
||||
packagings, which is what an app-grid search finds and what gets added to Steam as a non-Steam game.
|
||||
Its `Exec=punktfunk-client --browse --fullscreen` deliberately goes through the shell binary — that
|
||||
argv already execs the session, and it is the command flatpak's `Exec` rewrite expects.
|
||||
|
||||
The in-shell launch is a `gio::Subprocess`: `wait_check_async` lands the child's exit on the GTK
|
||||
main loop with no thread and no channel, releases `busy`, refreshes the host list (the console can
|
||||
pair hosts), and banners a non-zero exit — which is also how a session built without its `ui`
|
||||
feature surfaces ("--browse needs the console UI", exit non-zero).
|
||||
|
||||
**The Nix build does not install the new desktop entry**: it compiles the session
|
||||
`--no-default-features`, so that launcher could only ever print an error. Noted in
|
||||
`packaging/nix/README.md` next to the existing Skia caveat.
|
||||
|
||||
### Dependencies
|
||||
|
||||
**None.** No lockfile in the repo moves — not the workspace `Cargo.lock`, not the four under
|
||||
`tools/` and `packaging/windows/`, and no `package.json` under `web/`, `sdk/` or `plugin-kit/`.
|
||||
|
||||
### CI
|
||||
|
||||
`ci.yml`'s web test step widens from `bun test server/` to `bun test server/ nitro-entry/`, so the
|
||||
identity-selection gate runs where the origin-isolation gate already did. Both have the same
|
||||
property: a failure mode only a browser would catch.
|
||||
|
||||
---
|
||||
|
||||
## v0.31.3
|
||||
|
||||
41 commits since v0.31.2 (26 non-merge), counted at the tip this was cut from.
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3605,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3614,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3698,7 +3698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.31.3"
|
||||
version = "0.31.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.3"
|
||||
"version": "0.31.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -16,7 +16,8 @@ use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
use pf_console_ui::{
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, InputSource, Insets, Key, SnapshotStore,
|
||||
Viewport,
|
||||
};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::collections::VecDeque;
|
||||
@@ -346,7 +347,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
// Discrete events are the remote/keyboard path (Kotlin routes pad
|
||||
// buttons through PadSample) — with one wrinkle: a pad's SELECT also
|
||||
// arrives here (SkiaConsoleShell's ▲-on-Home shortcut), briefly
|
||||
// reading as keys. The next real pad press corrects the legend.
|
||||
if let Some(p) = console.menu(ev, InputSource::Keys) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
@@ -454,7 +459,7 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
menu_out.clear();
|
||||
nav.poll(&sample, Instant::now(), &mut menu_out);
|
||||
for ev in menu_out.drain(..) {
|
||||
if let Some(p) = console.menu(ev) {
|
||||
if let Some(p) = console.menu(ev, InputSource::Pad) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ final class SessionModel: ObservableObject {
|
||||
let hz = UInt32(clamping: effective.refreshHz)
|
||||
let compositor = PunktfunkConnection.Compositor(
|
||||
rawValue: UInt32(clamping: effective.compositor)) ?? .auto
|
||||
let bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
var bitrateKbps = UInt32(clamping: effective.bitrateKbps)
|
||||
let audioChannels = UInt8(clamping: effective.audioChannels)
|
||||
// The audio format this session ASKS for — the user's choice, at every channel count.
|
||||
//
|
||||
@@ -419,6 +419,15 @@ final class SessionModel: ObservableObject {
|
||||
let (audioRateHz, audioBits) = audioFormat.wire
|
||||
let hdrEnabled = effective.hdrEnabled
|
||||
let preferredCodec = PunktfunkConnection.codecByte(effective.codec)
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
|
||||
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
|
||||
// the host's operator ceiling — send 0 and let the host pin its per-mode rate. Gated
|
||||
// like the advertisement below: a device that failed the Metal probe never offers the
|
||||
// codec, falls back to H.26x, and the user's rate must survive there. The stored
|
||||
// setting is untouched, so switching codecs back restores it.
|
||||
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
|
||||
bitrateKbps = 0
|
||||
}
|
||||
let pin = host.pinnedSHA256
|
||||
// Capability gate (main-actor — screen APIs): only advertise HDR when this display can
|
||||
// actually present it, so the host sends a proper SDR stream to an SDR display rather than
|
||||
|
||||
@@ -256,11 +256,25 @@ extension SettingsView {
|
||||
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0 and the
|
||||
// host pins a per-mode rate, so a live rate control here would change nothing. Same
|
||||
// support gate as the codec picker offering the option; the stored rate is untouched,
|
||||
// so switching the codec back restores it.
|
||||
if effective.codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
described("PyroWave sets its own rate from the stream mode — a fixed bitrate "
|
||||
+ "doesn't apply.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: .constant(true))
|
||||
.disabled(true)
|
||||
}
|
||||
} else {
|
||||
described("Uses the host's default, 20 Mbps. Off to set it yourself.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
}
|
||||
}
|
||||
if effective.bitrateKbps != 0 {
|
||||
if effective.codec != "pyrowave" || !MetalWaveletDecoder.supported,
|
||||
effective.bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
|
||||
@@ -450,15 +450,24 @@ struct SettingsView: View {
|
||||
title: "Render scale",
|
||||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||
selection: $renderScale)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
// PyroWave is always Automatic (ABR overhaul RFC §5.2): the session sends 0
|
||||
// and the host pins a per-mode rate. tvOS has no codec picker, so this only
|
||||
// fires on a codec synced from another device — but the row must not offer a
|
||||
// rate the session ignores. The stored value is kept.
|
||||
if codec == "pyrowave", MetalWaveletDecoder.supported {
|
||||
tvCaption("PyroWave sets its own rate from the stream mode — the bitrate "
|
||||
+ "setting doesn't apply.")
|
||||
} else {
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
|
||||
@@ -795,6 +795,25 @@ fn pump(
|
||||
// rung at all, so advertising HEVC would promise what this build cannot keep.
|
||||
¶ms.decoder,
|
||||
) & !params.exclude_codecs;
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): a fixed kbps is
|
||||
// ill-defined for the all-intra codec (bpp is the operating point) and used to bypass
|
||||
// the host's `PUNKTFUNK_PYROWAVE_MAX_MBPS` ceiling. Send 0 and let the host pin; the
|
||||
// stored profile value is untouched, so switching codecs back restores it. Gated on the
|
||||
// codec actually being ADVERTISED: a pyrowave preference on a device that failed the
|
||||
// decode probe falls back to H.26x, where the user's explicit rate must survive.
|
||||
let bitrate_kbps = if preferred == punktfunk_core::quic::CODEC_PYROWAVE
|
||||
&& advertised_codecs & punktfunk_core::quic::CODEC_PYROWAVE != 0
|
||||
{
|
||||
if params.bitrate_kbps != 0 {
|
||||
tracing::info!(
|
||||
stored_kbps = params.bitrate_kbps,
|
||||
"PyroWave forces Automatic bitrate — asking the host for its per-mode pin"
|
||||
);
|
||||
}
|
||||
0
|
||||
} else {
|
||||
params.bitrate_kbps
|
||||
};
|
||||
if params.exclude_codecs != 0 {
|
||||
tracing::info!(
|
||||
excluded = params.exclude_codecs,
|
||||
@@ -840,7 +859,7 @@ fn pump(
|
||||
params.mode,
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
bitrate_kbps,
|
||||
params.video_caps,
|
||||
params.audio_channels,
|
||||
audio_rate_hz,
|
||||
|
||||
@@ -17,6 +17,17 @@ use skia_safe::Canvas;
|
||||
|
||||
pub use crate::input::Key;
|
||||
|
||||
/// What produced a menu event — the device family the hint legend should speak in.
|
||||
/// Pointer input carries no source on purpose: a tap says nothing about which buttons
|
||||
/// the user's OTHER hand holds, so it leaves the legend as it was.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InputSource {
|
||||
/// A gamepad (the glyphs follow the active pad's family).
|
||||
Pad,
|
||||
/// Keys: a TV remote's D-pad on Android, a keyboard on the desktop.
|
||||
Keys,
|
||||
}
|
||||
|
||||
/// Where the console starts.
|
||||
pub enum ConsoleEntry {
|
||||
/// The host list (the session binary's bare `--browse`; the Android console's Home).
|
||||
@@ -122,8 +133,12 @@ impl Console {
|
||||
.render_in(canvas, viewport, &self.fonts, pad, pad_pref, pads);
|
||||
}
|
||||
|
||||
/// A controller menu event. The pulse, if any, is what the pad should feel.
|
||||
pub fn menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
|
||||
/// A menu event, with WHERE it came from — a controller, or keys (a TV remote's
|
||||
/// D-pad, a keyboard). The source is what keeps the hint legend speaking the language
|
||||
/// of the device actually in the user's hand; the pulse, if any, is what a pad should
|
||||
/// feel.
|
||||
pub fn menu(&mut self, event: MenuEvent, source: InputSource) -> Option<MenuPulse> {
|
||||
self.shell.note_input_source(source);
|
||||
self.shell.handle_menu(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Controller button glyphs and the hint bar — the "controls legend" pill every console
|
||||
//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via
|
||||
//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad:
|
||||
//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with
|
||||
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
|
||||
//! drivable either way.
|
||||
//! `sfSymbolsName`; here the shapes are drawn). The style follows WHAT IS DRIVING the
|
||||
//! console (see `Shell::glyph_style`): PlayStation controllers read ✕/○/□/△, Nintendo
|
||||
//! pads read their own letter positions, everything else reads ABXY letters — and when
|
||||
//! the last input came from keys, the legend swaps to keyboard keycaps on the desktop or
|
||||
//! to TV-remote marks (OK, the back arrow, the D-pad) on Android, where key-driven input
|
||||
//! IS a remote. The console stays fully drivable in every one of them.
|
||||
|
||||
use crate::theme::{fg, fill, stroke, Fonts, W};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
@@ -15,16 +17,30 @@ pub(crate) enum GlyphStyle {
|
||||
Letters,
|
||||
/// PlayStation face shapes (DualSense / DualShock 4).
|
||||
Shapes,
|
||||
/// No controller — keyboard keycaps.
|
||||
/// Nintendo letter badges: the same positional buttons, labelled the way the pad in
|
||||
/// the user's hands is — south reads B, east A, west Y, north X. Without this a
|
||||
/// Switch pad's legend says "A Select" over the button engraved B.
|
||||
Nintendo,
|
||||
/// Keys drive, on a desktop — keyboard keycaps.
|
||||
Keyboard,
|
||||
/// Keys drive, on Android — a TV remote: OK, the back arrow, and the D-pad. A remote
|
||||
/// has no Y/X and no shoulders, so hints that need them resolve to nothing and the
|
||||
/// section hint points at the D-pad path instead.
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl GlyphStyle {
|
||||
/// The style a PAD speaks in, from the family its `Auto` virtual pad resolves to
|
||||
/// ([`PadInfo::pref`](pf_client_core::menu_nav::PadInfo) — DualSense stays DualSense,
|
||||
/// Switch Pro stays Switch Pro, everything else lands on an Xbox class). The keys-drive
|
||||
/// styles are picked by the shell, which knows the platform; `None` (no pad) falls to
|
||||
/// keycaps as the neutral default.
|
||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||
match pref {
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
||||
GlyphStyle::Shapes
|
||||
}
|
||||
Some(GamepadPref::SwitchPro) => GlyphStyle::Nintendo,
|
||||
Some(_) => GlyphStyle::Letters,
|
||||
None => GlyphStyle::Keyboard,
|
||||
}
|
||||
@@ -56,6 +72,30 @@ pub(crate) fn pad_mark(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if style == GlyphStyle::Remote {
|
||||
// A remote: a slim upright wand with its select ring near the top. Outlined like
|
||||
// the keycap — the filled marks are for things with a body to fill.
|
||||
let rw = w * 0.42;
|
||||
let rh = w * 0.98;
|
||||
let body = Rect::from_xywh(
|
||||
(x + (w - rw) / 2.0) as f32,
|
||||
(cy - rh / 2.0) as f32,
|
||||
rw as f32,
|
||||
rh as f32,
|
||||
);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.3 * k) as f32);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(body, (rw / 2.2) as f32, (rw / 2.2) as f32),
|
||||
&p,
|
||||
);
|
||||
canvas.draw_circle(
|
||||
((x + w / 2.0) as f32, (cy - rh * 0.22) as f32),
|
||||
(rw * 0.30) as f32,
|
||||
&p,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A gamepad: a wide rounded body with a grip under each end. Detail beyond the
|
||||
// silhouette is invisible at 15 dp, so there is none — the outline IS the glyph.
|
||||
let h = w * 0.52;
|
||||
@@ -201,17 +241,29 @@ pub(crate) fn hint_bar(
|
||||
let pad = 13.0 * k;
|
||||
let gap_hint = 18.0 * k;
|
||||
let gap_glyph = 7.0 * k;
|
||||
let widths: Vec<(f64, f64)> = hints
|
||||
// Hints with no honest glyph in this style (a remote's missing Y/X) are dropped
|
||||
// here, before layout — they take no width, draw nothing and get no hit box.
|
||||
let shown: Vec<&Hint> = hints
|
||||
.iter()
|
||||
.filter(|h| resolved(h.key, style).is_some())
|
||||
.collect();
|
||||
if shown.is_empty() {
|
||||
return HintBar {
|
||||
size: (0.0, 0.0),
|
||||
rects: Vec::new(),
|
||||
};
|
||||
}
|
||||
let widths: Vec<(f64, f64)> = shown
|
||||
.iter()
|
||||
.map(|h| {
|
||||
(
|
||||
glyph_width(fonts, h.key, style, k),
|
||||
glyph_width(fonts, h.key, style, k).expect("filtered to resolvable"),
|
||||
fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::<f64>()
|
||||
+ gap_hint * (hints.len() - 1) as f64;
|
||||
+ gap_hint * (shown.len() - 1) as f64;
|
||||
let h = BADGE_D * k + 2.0 * pad;
|
||||
let w = content_w + 2.0 * pad;
|
||||
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
|
||||
@@ -239,8 +291,8 @@ pub(crate) fn hint_bar(
|
||||
|
||||
let cy = bottom - h / 2.0;
|
||||
let mut pen = x + pad;
|
||||
let mut rects = Vec::with_capacity(hints.len());
|
||||
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
|
||||
let mut rects = Vec::with_capacity(shown.len());
|
||||
for (hint, (gw, lw)) in shown.iter().zip(&widths) {
|
||||
// Glyph + label + half the gap to the next hint, full pill height: a comfortable
|
||||
// target without stealing the neighbour's.
|
||||
rects.push((
|
||||
@@ -272,13 +324,14 @@ pub(crate) fn hint_bar(
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
/// `None` = the hint resolves to nothing in this style and takes no space (see [`resolved`]).
|
||||
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> Option<f64> {
|
||||
Some(match resolved(key, style)? {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down | Resolved::Ok | Resolved::BackArrow => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
|
||||
@@ -291,7 +344,7 @@ fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
|
||||
|
||||
/// A hint key resolved against the glyph style.
|
||||
enum Resolved {
|
||||
/// A face-button badge: the letter (Letters) or shape index (Shapes).
|
||||
/// A face-button badge: the letter (Letters/Nintendo) or shape (Shapes).
|
||||
Badge(Face),
|
||||
Shoulders,
|
||||
Adjust,
|
||||
@@ -301,6 +354,10 @@ enum Resolved {
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
/// A TV remote's select — a round badge that simply says OK.
|
||||
Ok,
|
||||
/// A TV remote's back — the ↩ return arrow in a badge.
|
||||
BackArrow,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -312,9 +369,14 @@ enum Face {
|
||||
Y,
|
||||
}
|
||||
|
||||
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
/// `None` = this hint has no honest glyph in this style and is not drawn at all: a TV
|
||||
/// remote has no Y/X, and advertising a button the device cannot press is worse than
|
||||
/// silence. (The touch path loses those two bar buttons in Remote style with it —
|
||||
/// acceptable: Remote only rules while KEYS drove last, and every such action still has
|
||||
/// an on-screen path.)
|
||||
fn resolved(key: HintKey, style: GlyphStyle) -> Option<Resolved> {
|
||||
if style == GlyphStyle::Keyboard {
|
||||
return match key {
|
||||
return Some(match key {
|
||||
HintKey::Confirm => Resolved::Key("Enter"),
|
||||
HintKey::Back => Resolved::Key("Esc"),
|
||||
HintKey::Secondary => Resolved::Key("Y"),
|
||||
@@ -326,9 +388,26 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
});
|
||||
}
|
||||
if style == GlyphStyle::Remote {
|
||||
return match key {
|
||||
HintKey::Confirm => Some(Resolved::Ok),
|
||||
HintKey::Back => Some(Resolved::BackArrow),
|
||||
// A remote has no Y and no X. The screens' Y/X features stay reachable the
|
||||
// ways their screens already provide; the legend just stops naming buttons
|
||||
// that are not in the user's hand.
|
||||
HintKey::Secondary | HintKey::Tertiary => None,
|
||||
// No shoulders either — the D-pad path to the strip (Up from the top row) is
|
||||
// the section switcher a remote actually has, so the hint points up.
|
||||
HintKey::Shoulders => Some(Resolved::Up),
|
||||
HintKey::Adjust => Some(Resolved::Adjust),
|
||||
HintKey::Up => Some(Resolved::Up),
|
||||
HintKey::Down => Some(Resolved::Down),
|
||||
HintKey::Key(t) => Some(Resolved::Key(t)),
|
||||
};
|
||||
}
|
||||
match key {
|
||||
Some(match key {
|
||||
HintKey::Confirm => Resolved::Badge(Face::A),
|
||||
HintKey::Back => Resolved::Badge(Face::B),
|
||||
HintKey::Tertiary => Resolved::Badge(Face::X),
|
||||
@@ -338,6 +417,21 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
})
|
||||
}
|
||||
|
||||
/// The letter a face badge shows: positional buttons, labelled the way the ACTIVE pad
|
||||
/// is engraved. Nintendo swaps both pairs — its south is B and its east is A.
|
||||
fn face_letter(face: Face, style: GlyphStyle) -> &'static str {
|
||||
match (style, face) {
|
||||
(GlyphStyle::Nintendo, Face::A) => "B",
|
||||
(GlyphStyle::Nintendo, Face::B) => "A",
|
||||
(GlyphStyle::Nintendo, Face::X) => "Y",
|
||||
(GlyphStyle::Nintendo, Face::Y) => "X",
|
||||
(_, Face::A) => "A",
|
||||
(_, Face::B) => "B",
|
||||
(_, Face::X) => "X",
|
||||
(_, Face::Y) => "Y",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +445,10 @@ fn draw_glyph(
|
||||
cy: f64,
|
||||
k: f64,
|
||||
) {
|
||||
match resolved(key, style) {
|
||||
let Some(resolved) = resolved(key, style) else {
|
||||
return;
|
||||
};
|
||||
match resolved {
|
||||
Resolved::Badge(face) => {
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let center = Point::new((x + r) as f32, cy as f32);
|
||||
@@ -360,12 +457,7 @@ fn draw_glyph(
|
||||
if style == GlyphStyle::Shapes {
|
||||
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
|
||||
} else {
|
||||
let letter = match face {
|
||||
Face::A => "A",
|
||||
Face::B => "B",
|
||||
Face::X => "X",
|
||||
Face::Y => "Y",
|
||||
};
|
||||
let letter = face_letter(face, style);
|
||||
let size = 12.0 * k;
|
||||
let w = fonts.measure(letter, W::SemiBold, size) as f64;
|
||||
fonts.draw(
|
||||
@@ -379,6 +471,50 @@ fn draw_glyph(
|
||||
);
|
||||
}
|
||||
}
|
||||
Resolved::Ok => {
|
||||
// The remote's select: the same badge as a face button, saying OK — the word
|
||||
// printed on the remote itself.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let center = Point::new((x + r) as f32, cy as f32);
|
||||
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
|
||||
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
|
||||
let size = 9.5 * k;
|
||||
let w = fonts.measure("OK", W::SemiBold, size) as f64;
|
||||
fonts.draw(
|
||||
canvas,
|
||||
"OK",
|
||||
x + r - w / 2.0,
|
||||
cy + size * 0.36,
|
||||
W::SemiBold,
|
||||
size,
|
||||
fg(0.92),
|
||||
);
|
||||
}
|
||||
Resolved::BackArrow => {
|
||||
// The remote's back: the ↩ return arrow in the same badge — a shaft curving
|
||||
// home with an arrowhead at its left end.
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let center = Point::new((x + r) as f32, cy as f32);
|
||||
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
|
||||
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
|
||||
let (cx, cyf) = (center.x, center.y);
|
||||
let (half_w, rise) = ((4.6 * k) as f32, (3.2 * k) as f32);
|
||||
let mut p = stroke(fg(0.92), (1.7 * k) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_stroke_join(skia_safe::PaintJoin::Round);
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to((cx + half_w, cyf - rise)); // the hook, up on the right…
|
||||
path.line_to((cx + half_w, cyf + rise * 0.2)); // …dropping to the shaft…
|
||||
path.line_to((cx - half_w, cyf + rise * 0.2)); // …running left toward the head.
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
let head = (2.6 * k) as f32;
|
||||
let tip = cx - half_w;
|
||||
let mut arrow = PathBuilder::new();
|
||||
arrow.move_to((tip + head, cyf + rise * 0.2 - head));
|
||||
arrow.line_to((tip, cyf + rise * 0.2));
|
||||
arrow.line_to((tip + head, cyf + rise * 0.2 + head));
|
||||
canvas.draw_path(&arrow.detach(), &p);
|
||||
}
|
||||
Resolved::Shoulders => {
|
||||
let mut pen = x;
|
||||
for label in ["L1", "R1"] {
|
||||
@@ -514,6 +650,62 @@ mod tests {
|
||||
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
|
||||
GlyphStyle::Letters
|
||||
);
|
||||
assert_eq!(
|
||||
GlyphStyle::from_pref(Some(GamepadPref::SwitchPro)),
|
||||
GlyphStyle::Nintendo
|
||||
);
|
||||
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
|
||||
}
|
||||
|
||||
/// Nintendo's badges carry the pad's OWN engravings: the positional confirm (south) is
|
||||
/// the button a Switch pad labels B. Everything non-Nintendo keeps the Xbox letters.
|
||||
#[test]
|
||||
fn nintendo_badges_read_the_pads_own_letters() {
|
||||
assert_eq!(face_letter(Face::A, GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(face_letter(Face::B, GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(face_letter(Face::X, GlyphStyle::Nintendo), "Y");
|
||||
assert_eq!(face_letter(Face::Y, GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(face_letter(Face::A, GlyphStyle::Letters), "A");
|
||||
}
|
||||
|
||||
/// A remote has no Y/X, so those hints resolve to nothing — the legend must not
|
||||
/// advertise a button the device in the user's hand cannot press. Confirm and Back
|
||||
/// resolve to the remote's own marks, and the section hint points at the D-pad path.
|
||||
#[test]
|
||||
fn remote_hides_the_buttons_a_remote_does_not_have() {
|
||||
assert!(resolved(HintKey::Secondary, GlyphStyle::Remote).is_none());
|
||||
assert!(resolved(HintKey::Tertiary, GlyphStyle::Remote).is_none());
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Confirm, GlyphStyle::Remote),
|
||||
Some(Resolved::Ok)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Back, GlyphStyle::Remote),
|
||||
Some(Resolved::BackArrow)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Shoulders, GlyphStyle::Remote),
|
||||
Some(Resolved::Up)
|
||||
));
|
||||
// Every other style resolves every hint — nothing else went silent.
|
||||
for style in [
|
||||
GlyphStyle::Letters,
|
||||
GlyphStyle::Shapes,
|
||||
GlyphStyle::Nintendo,
|
||||
GlyphStyle::Keyboard,
|
||||
] {
|
||||
for key in [
|
||||
HintKey::Confirm,
|
||||
HintKey::Back,
|
||||
HintKey::Secondary,
|
||||
HintKey::Tertiary,
|
||||
HintKey::Shoulders,
|
||||
HintKey::Adjust,
|
||||
HintKey::Up,
|
||||
HintKey::Down,
|
||||
] {
|
||||
assert!(resolved(key, style).is_some(), "{style:?} lost a hint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ mod theme;
|
||||
mod widgets;
|
||||
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub use console::{Console, ConsoleEntry, ConsoleHandles, Insets, Viewport};
|
||||
pub use console::{Console, ConsoleEntry, ConsoleHandles, InputSource, Insets, Viewport};
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub use input::Key;
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
|
||||
@@ -388,6 +388,7 @@ impl CollectionsScreen {
|
||||
Rect::from_ltrb(pills as f32, strip.top, strip.right, strip.bottom),
|
||||
&labels,
|
||||
selected,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
|
||||
@@ -1641,6 +1641,7 @@ impl LibraryScreen {
|
||||
),
|
||||
&sorts,
|
||||
sort_at,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
@@ -1660,6 +1661,7 @@ impl LibraryScreen {
|
||||
),
|
||||
&views,
|
||||
view_at,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::widgets::{
|
||||
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
|
||||
};
|
||||
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::menu_nav::{MenuDir, MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
@@ -384,6 +384,11 @@ pub(crate) struct SettingsScreen {
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
/// The tab strip holds the D-pad focus (Up from the list's top row steps onto it;
|
||||
/// Down/A step back off). This is how a device with ONLY a D-pad — a Chromecast/Google
|
||||
/// TV remote — switches tabs at all: the shoulder ring (L1/R1) and the Tab/PgUp/PgDn
|
||||
/// keys don't exist there, and a field report lost every tab but the first to that.
|
||||
strip_focus: bool,
|
||||
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
|
||||
/// the time. Every other row on this screen is a list of options, and a ladder is the
|
||||
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
|
||||
@@ -409,6 +414,7 @@ impl SettingsScreen {
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
strip_focus: false,
|
||||
custom_bitrate: None,
|
||||
keyboard: Keyboard::new(),
|
||||
}
|
||||
@@ -609,6 +615,11 @@ impl SettingsScreen {
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
}
|
||||
// A pointer press on the rows takes the focus back from the strip — direct
|
||||
// manipulation names its own target.
|
||||
if p.press() {
|
||||
self.strip_focus = false;
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||
@@ -628,6 +639,26 @@ impl SettingsScreen {
|
||||
if self.custom_bitrate.is_some() {
|
||||
return self.custom_menu(ev, ctx);
|
||||
}
|
||||
if self.strip_focus {
|
||||
// The strip holds the D-pad focus: left/right travel the ring, down/A drop back
|
||||
// to the rows, B still leaves the screen. This is the only tab path a device
|
||||
// with no shoulders and no Tab key has (a TV remote).
|
||||
return match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
None
|
||||
}
|
||||
MenuEvent::Move(MenuDir::Left) | MenuEvent::JumpBack => self.switch_tab(-1, ctx),
|
||||
MenuEvent::Move(MenuDir::Right) | MenuEvent::JumpForward => self.switch_tab(1, ctx),
|
||||
MenuEvent::Move(MenuDir::Down) | MenuEvent::Confirm => {
|
||||
self.strip_focus = false;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
// The top of the screen — the same recoil the list's ends answer with.
|
||||
MenuEvent::Move(MenuDir::Up) => Some(MenuPulse::Boundary),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
@@ -635,14 +666,23 @@ impl SettingsScreen {
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||
// Up from the top row steps onto the tab strip instead of recoiling — the
|
||||
// D-pad-only path to the other tabs.
|
||||
MenuEvent::Move(MenuDir::Up) if self.list.cursor == 0 => {
|
||||
self.strip_focus = true;
|
||||
return Some(MenuPulse::Move);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
|
||||
// and the hint bar only offers it where it does.
|
||||
// and the hint bar only offers it where it does. Not under PyroWave — the row is
|
||||
// dimmed (see `row_spec`) and a typed rate would be as inert as the ladder.
|
||||
if ev == MenuEvent::Secondary {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
|
||||
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate)
|
||||
&& ctx.settings.codec != "pyrowave"
|
||||
{
|
||||
self.custom_bitrate = Some(String::new());
|
||||
Some(MenuPulse::Confirm)
|
||||
} else {
|
||||
@@ -765,6 +805,15 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
// The strip has the focus (a D-pad-only remote's tab path): say what the D-pad does
|
||||
// up here, not what the rows would do.
|
||||
if self.strip_focus {
|
||||
return vec![
|
||||
Hint::new(HintKey::Adjust, "Section"),
|
||||
Hint::new(HintKey::Confirm, "Rows"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
@@ -778,6 +827,11 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
// Dimmed under PyroWave (row_spec): offering "Adjust" on an inert row would
|
||||
// teach a control that answers with a thud.
|
||||
Some(RowId::Bitrate) if ctx.settings.codec == "pyrowave" => {
|
||||
vec![Hint::new(HintKey::Back, "Done")]
|
||||
}
|
||||
// The one row with a value the ladder cannot name every version of.
|
||||
Some(RowId::Bitrate) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
@@ -812,6 +866,7 @@ impl SettingsScreen {
|
||||
Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32),
|
||||
&labels,
|
||||
self.tab,
|
||||
self.strip_focus,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
@@ -857,12 +912,13 @@ impl SettingsScreen {
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.custom_bitrate.is_none(),
|
||||
// The rows rest their focus ring while the keyboard tray or the strip holds it.
|
||||
self.custom_bitrate.is_none() && !self.strip_focus,
|
||||
);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
.copied()
|
||||
.map_or("", |id| detail(id, ctx.platform));
|
||||
.map_or("", |id| detail(id, ctx));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -994,6 +1050,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// that one is different.
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
// PyroWave is always Automatic bitrate (ABR overhaul RFC §5.2): the session sends 0
|
||||
// whatever this row stores and the host pins a per-mode bpp rate. Dimmed, not live —
|
||||
// a control that changes nothing must say so. The stored rate is kept: switching the
|
||||
// codec back restores it.
|
||||
RowId::Bitrate => s.codec != "pyrowave",
|
||||
// ⚠ Lossless follows the channel count for a reason that has MOVED, and the old reason
|
||||
// is still written down in several places that are now wrong (`hi-res-audio.md` §4.2's
|
||||
// blanket "surround does not fit a datagram", and `trust::Settings::audio_format`'s doc
|
||||
@@ -1253,8 +1314,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
/// The focused row's one-line explainer. Takes the platform because two desktop rows
|
||||
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
|
||||
/// shortcut the device cannot press must not be taught.
|
||||
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
fn detail(id: RowId, ctx: &Ctx) -> &'static str {
|
||||
use crate::platform::Platform;
|
||||
let platform = ctx.platform;
|
||||
match id {
|
||||
RowId::Resolution => {
|
||||
"The host creates a virtual display at exactly this size — no scaling. \
|
||||
@@ -1265,6 +1327,10 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
|
||||
"The host renders larger or smaller than the stream mode and this window \
|
||||
resamples — above 1× supersamples, below saves bandwidth."
|
||||
}
|
||||
RowId::Bitrate if ctx.settings.codec == "pyrowave" => {
|
||||
"PyroWave sets its own rate from the stream mode (all-intra) — a fixed bitrate \
|
||||
doesn't apply. Pick another codec to use this setting."
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
|
||||
}
|
||||
@@ -1525,6 +1591,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
.map(|i| s.render_scale = RENDER_SCALES[i])
|
||||
}
|
||||
RowId::Bitrate => {
|
||||
// Inert under PyroWave — a boundary thud, matching what the dimmed row shows
|
||||
// (the host pins the rate; see `row_spec`).
|
||||
if s.codec == "pyrowave" {
|
||||
return false;
|
||||
}
|
||||
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
|
||||
// the generic step snaps a value it cannot find to the first option — which here
|
||||
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
|
||||
@@ -2097,6 +2168,40 @@ pub(super) mod tests {
|
||||
assert!(ctx.settings.echo_cancel);
|
||||
}
|
||||
|
||||
/// Bitrate follows the codec: dimmed and inert under PyroWave (the host pins a per-mode
|
||||
/// rate and the session sends 0 — ABR overhaul RFC §5.2), live for every other codec,
|
||||
/// and the stored rate survives the dim so switching back restores it.
|
||||
#[test]
|
||||
fn bitrate_dims_under_pyrowave() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
settings.codec = "pyrowave".into();
|
||||
settings.bitrate_kbps = 80_000;
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::Bitrate, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::Bitrate, 1, false, &mut ctx),
|
||||
"pyrowave = thud"
|
||||
);
|
||||
assert!(!adjust(RowId::Bitrate, 1, true, &mut ctx), "A too");
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 80_000, "the stored rate is kept");
|
||||
|
||||
ctx.settings.codec = "hevc".into();
|
||||
assert!(row_spec(RowId::Bitrate, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
}
|
||||
|
||||
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||
@@ -2718,6 +2823,50 @@ pub(super) mod tests {
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// A D-pad alone reaches every tab: Up from the top row steps onto the strip,
|
||||
/// left/right travel it, Down drops back into the rows. This is the only tab path a
|
||||
/// Chromecast/Google TV remote has — no shoulders, no Tab key — and it regressed to
|
||||
/// "first tab only" when the rows were split across tabs.
|
||||
#[test]
|
||||
fn dpad_alone_reaches_every_tab() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
// Up from the top row focuses the strip instead of recoiling…
|
||||
assert_eq!(s.list.cursor, 0);
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(s.strip_focus, "Up from the top row lands on the strip");
|
||||
// …right travels the ring…
|
||||
s.menu(MenuEvent::Move(MenuDir::Right), &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 1);
|
||||
assert!(s.strip_focus, "switching keeps the strip focused");
|
||||
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, PROFILES_TAB, "the strip wraps like the shoulders do");
|
||||
// …and Down returns to the rows of the tab that's showing.
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(!s.strip_focus, "Down drops back into the list");
|
||||
// While the list has focus, Left/Right still adjust rows — only the top row's Up
|
||||
// reaches the strip, so a value row's chevrons keep meaning what they say.
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(!s.strip_focus);
|
||||
// Focusing the strip is navigation, never a settings write.
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// The lossless opt-in: it ships OFF, steps the cross-client table verbatim, sits directly
|
||||
/// under the channel count, and follows it — dim and inert under 5.1/7.1, because this
|
||||
/// client's session refuses to ASK for lossless surround (see the `enabled` note in
|
||||
|
||||
@@ -289,6 +289,12 @@ pub(crate) struct Shell {
|
||||
/// (or out of) calm alongside the screen transition.
|
||||
bg_mix: f64,
|
||||
glyphs: GlyphStyle,
|
||||
/// What drove the console LAST — a pad or keys — noted at the input seams
|
||||
/// ([`Shell::note_input_source`], [`Shell::key`]) and read by the per-frame glyph
|
||||
/// resolution, so the legend speaks the language of the device actually in use.
|
||||
/// `None` until anything drives: the style then follows the connected pad, or the
|
||||
/// platform's key device where there is none.
|
||||
input_source: Option<crate::console::InputSource>,
|
||||
chip: Option<String>,
|
||||
pads: Vec<PadInfo>,
|
||||
/// The settled top screen's hint-bar hit boxes, republished every frame by
|
||||
@@ -373,6 +379,7 @@ impl Shell {
|
||||
ink,
|
||||
bg_mix,
|
||||
glyphs: GlyphStyle::Keyboard,
|
||||
input_source: None,
|
||||
chip: None,
|
||||
pads: Vec::new(),
|
||||
hint_rects: Vec::new(),
|
||||
@@ -997,6 +1004,13 @@ impl Shell {
|
||||
consumed
|
||||
}
|
||||
|
||||
/// Note what produced the menu events now arriving — the hint legend follows it.
|
||||
/// Called by [`crate::console::Console::menu`] (which is told by its host) and the
|
||||
/// overlay's pad path; the keyboard path notes itself in [`Shell::key`].
|
||||
pub(crate) fn note_input_source(&mut self, source: crate::console::InputSource) {
|
||||
self.input_source = Some(source);
|
||||
}
|
||||
|
||||
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
|
||||
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
|
||||
/// (suppressed while editing, where letters are text).
|
||||
@@ -1004,6 +1018,7 @@ impl Shell {
|
||||
/// `shift` only matters for Tab, whose two directions are one key.
|
||||
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
|
||||
use crate::input::Key as S;
|
||||
self.input_source = Some(crate::console::InputSource::Keys);
|
||||
if self.editing() {
|
||||
let mut ctx = Ctx {
|
||||
hosts: &self.hosts,
|
||||
|
||||
@@ -81,12 +81,16 @@ impl Shell {
|
||||
let reduce = self.settings.reduce_motion;
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.glyphs = glyph_style(self.input_source, pad_pref, self.platform);
|
||||
// Compared before it is rebuilt: this string changes when someone plugs a controller
|
||||
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
|
||||
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
|
||||
// `PartialEq` in another crate to be worth the same treatment.)
|
||||
let chip = pad.unwrap_or("No controller — keyboard works too");
|
||||
let chip = pad.unwrap_or(if self.glyphs == GlyphStyle::Remote {
|
||||
"TV remote — a controller works too"
|
||||
} else {
|
||||
"No controller — keyboard works too"
|
||||
});
|
||||
if self.chip.as_deref() != Some(chip) {
|
||||
self.chip = Some(chip.to_owned());
|
||||
}
|
||||
@@ -427,3 +431,86 @@ impl LayerEnv<'_> {
|
||||
rects
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph style for this frame: the last input source rules — keys speak the
|
||||
/// platform's key device (a TV remote on Android, a keyboard on the desktop), a pad
|
||||
/// speaks its own family ([`GlyphStyle::from_pref`]). Before anything has driven, the
|
||||
/// connected pad's family shows if there is one (a pad in hand is what a fresh console
|
||||
/// will most likely be driven by), else the platform's key device.
|
||||
fn glyph_style(
|
||||
source: Option<crate::console::InputSource>,
|
||||
pad_pref: Option<punktfunk_core::config::GamepadPref>,
|
||||
platform: crate::platform::Platform,
|
||||
) -> GlyphStyle {
|
||||
let keys = || match platform {
|
||||
crate::platform::Platform::Android => GlyphStyle::Remote,
|
||||
crate::platform::Platform::Desktop => GlyphStyle::Keyboard,
|
||||
};
|
||||
match (source, pad_pref) {
|
||||
(Some(crate::console::InputSource::Keys), _) => keys(),
|
||||
(_, Some(p)) => GlyphStyle::from_pref(Some(p)),
|
||||
(_, None) => keys(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glyph_style_tests {
|
||||
use super::*;
|
||||
use crate::console::InputSource;
|
||||
use crate::platform::Platform;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
/// The matrix the field report walked: a Chromecast (Android, no pad) used to show
|
||||
/// keyboard keycaps — Enter/Esc/Tab, none of which its remote has. Keys on Android
|
||||
/// now read as the remote, keys on the desktop as the keyboard, a driving pad as its
|
||||
/// own family — and a pad that vanishes mid-session falls back to the platform's key
|
||||
/// device rather than freezing on the departed pad's letters.
|
||||
#[test]
|
||||
fn the_legend_follows_what_drives() {
|
||||
let xbox = Some(GamepadPref::Xbox360);
|
||||
// Untouched console: the connected pad's family, else the platform's key device.
|
||||
assert_eq!(
|
||||
glyph_style(None, xbox, Platform::Android),
|
||||
GlyphStyle::Letters
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(None, None, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(None, None, Platform::Desktop),
|
||||
GlyphStyle::Keyboard
|
||||
);
|
||||
// Keys drove last: the key device, even with a pad still connected.
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Keys), xbox, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Keys), xbox, Platform::Desktop),
|
||||
GlyphStyle::Keyboard
|
||||
);
|
||||
// A pad drove last: its family — and Nintendo reads Nintendo.
|
||||
assert_eq!(
|
||||
glyph_style(
|
||||
Some(InputSource::Pad),
|
||||
Some(GamepadPref::SwitchPro),
|
||||
Platform::Desktop
|
||||
),
|
||||
GlyphStyle::Nintendo
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(
|
||||
Some(InputSource::Pad),
|
||||
Some(GamepadPref::DualSense),
|
||||
Platform::Android
|
||||
),
|
||||
GlyphStyle::Shapes
|
||||
);
|
||||
// The pad drove, then unplugged: back to the platform's key device.
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Pad), None, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,6 +1348,17 @@ fn dump_console_screens() {
|
||||
s.set_connecting(None);
|
||||
s.session_failed("Connection timed out");
|
||||
dump(&mut s, 10, 8, "10-toast", true);
|
||||
|
||||
// The TV-remote legend (Android platform, keys driving): the OK and ↩ badges, the
|
||||
// ▲ section pointer, the hidden Y/X hints, and the remote chip mark — Home and the
|
||||
// hint-dense Settings. The platform flip is legends-only for these two frames; the
|
||||
// stack was built desktop, so only the glyphs and the Android row set differ.
|
||||
dump(&mut s, 30, 8, "_remote-settle", true);
|
||||
s.platform = crate::platform::Platform::Android;
|
||||
s.note_input_source(crate::console::InputSource::Keys);
|
||||
dump(&mut s, 40, 8, "11-home-remote", false);
|
||||
s.handle_menu(MenuEvent::Tertiary);
|
||||
dump(&mut s, 40, 8, "11b-settings-remote", false);
|
||||
}
|
||||
|
||||
/// A 2:3 poster, PNG-encoded, in a colour derived from `seed`.
|
||||
|
||||
@@ -301,7 +301,12 @@ impl Overlay for SkiaOverlay {
|
||||
|
||||
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
|
||||
if self.console_visible() {
|
||||
self.shell.as_mut().and_then(|s| s.handle_menu(event))
|
||||
self.shell.as_mut().and_then(|s| {
|
||||
// The presenter's menu_rx carries pad events only (its keyboard goes
|
||||
// through `key`), so this seam IS the pad source note.
|
||||
s.note_input_source(crate::console::InputSource::Pad);
|
||||
s.handle_menu(event)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -708,6 +708,10 @@ impl TabStrip {
|
||||
/// Draw the pills along the leading edge of `rect`'s top band, at the same
|
||||
/// [`EDGE_INSET`] the heading above them uses. Returns nothing — the caller already
|
||||
/// knows the band is [`TAB_STRIP_H`] tall.
|
||||
///
|
||||
/// `focused` = the strip itself holds the D-pad focus (a remote with no shoulder
|
||||
/// buttons steps onto it from the list's top row): the highlight brightens and grows
|
||||
/// ‹ › chevrons, the same affordance a focused value row shows for left/right.
|
||||
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
@@ -715,6 +719,7 @@ impl TabStrip {
|
||||
rect: Rect,
|
||||
labels: &[&str],
|
||||
selected: usize,
|
||||
focused: bool,
|
||||
fonts: &Fonts,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
@@ -770,10 +775,16 @@ impl TabStrip {
|
||||
canvas,
|
||||
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
|
||||
(pill_h / 2.0 / k) as f32,
|
||||
Some(accent(0.85)),
|
||||
PanelStroke::Plain(0.22),
|
||||
Some(accent(if focused { 1.0 } else { 0.85 })),
|
||||
PanelStroke::Plain(if focused { 0.5 } else { 0.22 }),
|
||||
k as f32,
|
||||
);
|
||||
if focused {
|
||||
// The focused value row's ‹ › affordance, on the strip: left/right travel here.
|
||||
let cy = top + pill_h / 2.0;
|
||||
chevron(canvas, ix - 9.0 * k, cy, 4.0 * k, true, 0.9);
|
||||
chevron(canvas, ix + iw + 9.0 * k, cy, 4.0 * k, false, 0.9);
|
||||
}
|
||||
|
||||
let baseline = top + pill_h / 2.0 + size * 0.36;
|
||||
self.pills.clear();
|
||||
@@ -1277,18 +1288,18 @@ mod tests {
|
||||
let dt = 1.0 / 60.0;
|
||||
// Seat on the first tab, then a 5-step burst at one press per frame — far faster
|
||||
// than the spring can settle, which is the whole point.
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, 1.0, dt);
|
||||
let mut worst_left = f64::MAX;
|
||||
let mut worst_right = f64::MIN;
|
||||
for sel in 1..=5 {
|
||||
strip.render(surface.canvas(), rect, &TABS, sel, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, sel, false, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
}
|
||||
// Then let it land.
|
||||
for _ in 0..240 {
|
||||
strip.render(surface.canvas(), rect, &TABS, 5, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 5, false, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
@@ -1324,7 +1335,7 @@ mod tests {
|
||||
let mut run = |w: f32, k: f64| {
|
||||
let rect = Rect::from_xywh(0.0, 0.0, w, (TAB_STRIP_H * k) as f32);
|
||||
let mut strip = TabStrip::new();
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, k, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, k, dt);
|
||||
let first = strip.pill(0).expect("the first section was drawn");
|
||||
let last = strip
|
||||
.pill(TABS.len() - 1)
|
||||
|
||||
@@ -137,11 +137,22 @@ impl KwinDisplay {
|
||||
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
|
||||
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
|
||||
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
|
||||
///
|
||||
/// `pre_enabled` is the non-managed outputs that were enabled BEFORE the virtual output was
|
||||
/// created ([`enabled_physicals`], captured in `create`). It exists because KWin reacts to our
|
||||
/// output appearing by applying its stored setup for the NEW monitor set
|
||||
/// (`kwinoutputconfig.json`), and a set that ever ran `exclusive` has "physicals disabled"
|
||||
/// stored — so the physicals can be OFF by the time any post-create enumeration runs. A field
|
||||
/// report (Bazzite, triple-monitor) showed exactly that: `also_disabled=[]` on an exclusive
|
||||
/// apply, so teardown restored nothing and the desk stayed dark. The snapshot is the only read
|
||||
/// KWin's reaction cannot have polluted: under `Exclusive` it joins the restore list, under
|
||||
/// every other topology [`reenable_stranded`] puts the stored-config casualties back on.
|
||||
fn apply_topology(
|
||||
&mut self,
|
||||
name: &str,
|
||||
our_prefix: &str,
|
||||
dims: (u32, u32),
|
||||
pre_enabled: &[(String, String)],
|
||||
) -> Vec<(String, String)> {
|
||||
use crate::kwin_output_mgmt::TopologyKind;
|
||||
use crate::policy::Topology;
|
||||
@@ -155,6 +166,10 @@ impl KwinDisplay {
|
||||
// (stable) output name for whatever monitor set it was saved under. Applies only if
|
||||
// it really is mirroring; nothing else about the user's arrangement is touched.
|
||||
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
|
||||
// ...and the same goes for the stored ENABLED state: these topologies promise the
|
||||
// user's screens stay untouched, so KWin switching them off in reaction to our
|
||||
// output appearing is undone, not honored.
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
@@ -162,7 +177,13 @@ impl KwinDisplay {
|
||||
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||
if outcome.handled {
|
||||
self.our_uuid = outcome.our_uuid;
|
||||
return outcome.disabled;
|
||||
if kind == TopologyKind::Primary {
|
||||
// `Primary` keeps the physicals enabled by contract — undo KWin's stored-config
|
||||
// disable exactly as the Extend arm does. Nothing to restore at teardown.
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
return outcome.disabled;
|
||||
}
|
||||
return union_restore(outcome.disabled, pre_enabled);
|
||||
}
|
||||
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
|
||||
tracing::info!(
|
||||
@@ -171,9 +192,10 @@ impl KwinDisplay {
|
||||
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
|
||||
self.last_name = Some(addr.clone());
|
||||
match topology {
|
||||
Topology::Exclusive => apply_virtual_primary(&addr),
|
||||
Topology::Exclusive => union_restore(apply_virtual_primary(&addr), pre_enabled),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&addr);
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
Vec::new()
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
@@ -324,6 +346,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// install — the output is born at the real size and 60 Hz is the offer anyway.
|
||||
let want_high = mode.refresh_hz > 60;
|
||||
let birth_h = if want_high { height + 16 } else { height };
|
||||
// Snapshot the enabled physicals BEFORE the virtual output exists: creating it changes the
|
||||
// monitor set, and KWin may apply a stored setup for the new set that disables them (see
|
||||
// `apply_topology`). Everything read after this point can already be polluted by that.
|
||||
let pre_enabled = enabled_physicals();
|
||||
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
||||
// `requested_*`, NOT `width`/`height`: `spawn_vout` hands back a node id, never a size, so
|
||||
// every number on this line is what we ASKED for. Logged as `width=… height=…` it read like
|
||||
@@ -534,7 +560,7 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
|
||||
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
|
||||
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
|
||||
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
|
||||
let disabled = self.apply_topology(&name, &our_prefix, final_dims, &pre_enabled);
|
||||
// `last_name` is already the best address we have: `Virtual-<name>` from the top of this
|
||||
// function, upgraded in place to the RESOLVED numeric kscreen id by whichever of the
|
||||
// `want_high` fallback or `apply_topology`'s fallback actually ran a resolve. Nothing to
|
||||
@@ -561,6 +587,21 @@ impl VirtualDisplay for KwinDisplay {
|
||||
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
|
||||
reenable_outputs_kscreen(&disabled);
|
||||
}
|
||||
// This ran BEFORE our output is reclaimed (§6.1 ordering, so KWin never sees zero
|
||||
// outputs) — which means it applied under the WITH-us monitor set. Reclaiming the
|
||||
// output then flips KWin to the without-us set, whose stored setup can put the
|
||||
// physicals straight back to disabled (how the field report's desk ended up dark
|
||||
// after every stream). One delayed re-assert lands after the reclaim, under THAT
|
||||
// set — and being a user-applied config, KWin persists it there, healing the
|
||||
// stored setup instead of re-fighting it next session. One shot, never a loop.
|
||||
let verify = disabled.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-kwin-restore-verify".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(STRAND_RECHECK_DELAY);
|
||||
reenable_pass(&verify, "post-teardown", true);
|
||||
})
|
||||
.ok();
|
||||
}) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
@@ -575,6 +616,95 @@ impl VirtualDisplay for KwinDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long after a create/teardown to re-check for outputs KWin's stored setup switched off.
|
||||
/// KWin applies the stored setup for a changed monitor set promptly, but not synchronously with
|
||||
/// our reads — the immediate pass catches the common case, this delayed one the late apply.
|
||||
const STRAND_RECHECK_DELAY: Duration = Duration::from_millis(2000);
|
||||
|
||||
/// The non-managed outputs currently enabled, each `(name, "WxH@Hz")` — the same spec shape the
|
||||
/// restore path stores, so the two lists interchange. Empty when output management is unavailable
|
||||
/// (the callers then simply keep today's behavior).
|
||||
fn enabled_physicals() -> Vec<(String, String)> {
|
||||
crate::kwin_output_mgmt::list_monitors()
|
||||
.map(|ms| {
|
||||
ms.into_iter()
|
||||
.filter(|m| m.enabled && !m.managed && m.width > 0 && m.height > 0)
|
||||
.map(|m| {
|
||||
let hz = ((m.refresh_mhz as f64) / 1000.0).round() as u32;
|
||||
(m.connector, format!("{}x{}@{hz}", m.width, m.height))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Undo KWin's stored-config reaction to our output appearing: any output in `pre_enabled` (lit
|
||||
/// BEFORE the virtual output was created) that is disabled now was switched off by KWin's persisted
|
||||
/// setup for the new monitor set — the enabled-state sibling of the stored `replicationSource`
|
||||
/// that [`crate::kwin_output_mgmt::clear_replication_source`] clears. Two passes: one now, one
|
||||
/// after [`STRAND_RECHECK_DELAY`] (KWin can apply the stored setup after our first read). Each is
|
||||
/// one shot, so a user's own later disable stays honored.
|
||||
fn reenable_stranded(pre_enabled: Vec<(String, String)>) {
|
||||
if pre_enabled.is_empty() {
|
||||
return;
|
||||
}
|
||||
reenable_pass(&pre_enabled, "immediate", false);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-kwin-reenable".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(STRAND_RECHECK_DELAY);
|
||||
reenable_pass(&pre_enabled, "delayed", false);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// One re-enable pass: re-read the outputs, re-enable `expected ∩ now-disabled`. `abort_if_managed`
|
||||
/// is the post-teardown guard: a managed output present by then means a NEW session already owns
|
||||
/// the topology (a quick reconnect), and lighting the physicals under its `exclusive` would undo
|
||||
/// it — that session's own restore covers them instead.
|
||||
fn reenable_pass(expected: &[(String, String)], wave: &'static str, abort_if_managed: bool) {
|
||||
let Ok(now) = crate::kwin_output_mgmt::list_monitors() else {
|
||||
return;
|
||||
};
|
||||
if abort_if_managed && now.iter().any(|m| m.managed) {
|
||||
return;
|
||||
}
|
||||
let dark: Vec<(String, String)> = expected
|
||||
.iter()
|
||||
.filter(|(name, _)| now.iter().any(|m| &m.connector == name && !m.enabled))
|
||||
.cloned()
|
||||
.collect();
|
||||
if dark.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
outputs = ?dark,
|
||||
wave,
|
||||
"KWin's stored output setup (kwinoutputconfig.json) left physical output(s) disabled that \
|
||||
the current topology says stay enabled — re-enabling them"
|
||||
);
|
||||
if !crate::kwin_output_mgmt::reenable_outputs(&dark) {
|
||||
reenable_outputs_kscreen(&dark);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `exclusive` restore list: what this apply disabled, plus every `pre_enabled` output not
|
||||
/// already in it — an output KWin's stored setup disabled in the create window was invisible to
|
||||
/// the apply's own enumeration, and dropping it from the list is how a teardown restored nothing
|
||||
/// on a triple-monitor box (`also_disabled=[]`). Re-enabling an output that was never actually
|
||||
/// disabled is a documented no-op, so the union errs on the side of the desk lighting up.
|
||||
fn union_restore(
|
||||
mut disabled: Vec<(String, String)>,
|
||||
pre_enabled: &[(String, String)],
|
||||
) -> Vec<(String, String)> {
|
||||
for (name, spec) in pre_enabled {
|
||||
if !disabled.iter().any(|(n, _)| n == name) {
|
||||
disabled.push((name.clone(), spec.clone()));
|
||||
}
|
||||
}
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
|
||||
/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
|
||||
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
|
||||
@@ -2044,10 +2174,34 @@ fn await_created(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow,
|
||||
MANAGED_PREFIX,
|
||||
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode,
|
||||
union_restore, KModeRow, MANAGED_PREFIX,
|
||||
};
|
||||
|
||||
/// The field failure the union guards: KWin's stored setup for the with-us monitor set
|
||||
/// disabled the physicals in the window between our output's creation and the exclusive
|
||||
/// apply's enumeration, so the apply saw nothing enabled (`also_disabled=[]`) and teardown
|
||||
/// restored nothing — a triple-monitor desk stranded dark. The pre-create snapshot must
|
||||
/// reach the restore list; what the apply itself disabled keeps its (fresher) entry.
|
||||
#[test]
|
||||
fn the_restore_list_covers_outputs_kwin_disabled_before_the_apply_saw_them() {
|
||||
let pre = vec![
|
||||
("DP-1".to_string(), "2560x1440@144".to_string()),
|
||||
("DP-2".to_string(), "2560x1440@60".to_string()),
|
||||
("DP-3".to_string(), "1920x1080@60".to_string()),
|
||||
];
|
||||
// The log's case: the apply enumerated nothing enabled.
|
||||
assert_eq!(union_restore(Vec::new(), &pre), pre);
|
||||
// The healthy case: the apply's own capture wins for the outputs it saw (its spec is
|
||||
// the fresher read), and the snapshot only fills the gaps.
|
||||
let seen = vec![("DP-1".to_string(), "2560x1440@120".to_string())];
|
||||
let merged = union_restore(seen, &pre);
|
||||
assert_eq!(merged.len(), 3);
|
||||
assert_eq!(merged[0], ("DP-1".to_string(), "2560x1440@120".to_string()));
|
||||
assert!(merged.contains(&("DP-2".to_string(), "2560x1440@60".to_string())));
|
||||
assert!(merged.contains(&("DP-3".to_string(), "1920x1080@60".to_string())));
|
||||
}
|
||||
|
||||
/// The field failure this predicate now guards, in the shape the log reported it: a client
|
||||
/// negotiated 3840x2160, KWin restored a stored 1920x1080 for the output name, and nothing
|
||||
/// compared the two — so the session captured 1080p, encoded 1080p, and shipped it to a client
|
||||
|
||||
@@ -643,9 +643,11 @@ impl Reassembler {
|
||||
// Slice frames have no uniform shape to demand — the invariant is positional:
|
||||
// every sentinel block must sit strictly below the final block's base
|
||||
// (`total_data − data_shards`; the firewall already proved the subtraction
|
||||
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed —
|
||||
// the sender is AEAD-authenticated, so a lying base can only corrupt this
|
||||
// frame's own pixels, never memory (placement stays in-bounds by these checks).
|
||||
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
|
||||
// HERE — placement stays in-bounds by these checks, so a lying base can only
|
||||
// corrupt this frame's own bytes, never memory — but the completion tiling
|
||||
// check below refuses to deliver such a frame (black-band corruption from a
|
||||
// buggy, AEAD-authenticated sender would otherwise ship as `complete`).
|
||||
let final_base = total_data - data_shards;
|
||||
frame.blocks.iter().any(|(&bi, b)| {
|
||||
let bi = bi as usize;
|
||||
@@ -881,6 +883,31 @@ impl Reassembler {
|
||||
reconstructed_shards(&done.blocks, lim.max_data_shards),
|
||||
);
|
||||
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
|
||||
// Slice-streamed frames: every base was bounds-checked on arrival (in range,
|
||||
// below the final block) but nothing yet proved the blocks TILE the AU. A base
|
||||
// that lies WITHIN bounds leaves a zero gap and an overlap — wrong bytes in a
|
||||
// frame stamped `complete`, which the decoder paints as garbage rectangles and
|
||||
// no loss counter ever moves. Refuse to deliver: the index is already in
|
||||
// `completed` (stragglers can't resurrect it), so just count the loss — the
|
||||
// `frames_dropped` climb is what fires the client's recovery request.
|
||||
if done.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0 {
|
||||
let total_data = done.frame_bytes.div_ceil(done.shard_bytes).max(1);
|
||||
let mut next = 0usize;
|
||||
let tiled = (0..block_count).all(|bi| match done.blocks.get(&(bi as u16)) {
|
||||
Some(b) if b.base_shard == next => {
|
||||
next += b.data_shards;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}) && next == total_data;
|
||||
if !tiled {
|
||||
if !is_probe {
|
||||
StatsCounters::add(&stats.frames_dropped, 1);
|
||||
}
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
|
||||
// Slice-progressive consumers already hold the delivered prefix — the completing
|
||||
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
|
||||
@@ -925,6 +952,12 @@ impl Reassembler {
|
||||
if block_count != 0 && (*next_part_block as usize) + 1 >= block_count {
|
||||
break;
|
||||
}
|
||||
// A prefix is only a prefix if this block starts where the last one ended —
|
||||
// a slice block whose wire base lies within bounds must not extend it (the
|
||||
// frame then dies at the completion tiling check above).
|
||||
if b.base_shard != *delivered_shards {
|
||||
break;
|
||||
}
|
||||
*delivered_shards = b.base_shard + b.data_shards;
|
||||
*next_part_block += 1;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1186,56 @@ fn slice_streamed_lying_final_kills_frame() {
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||
}
|
||||
|
||||
/// Completion tiling check: a sentinel base that lies WITHIN every bounds check (in range,
|
||||
/// below the final block) but breaks the tiling — a gap at the honest base, an overlap at
|
||||
/// the claimed one — must NOT be delivered as a `complete` frame (the black-band corruption
|
||||
/// shape: wrong-offset bytes with zeros in the gap and no loss counter moving). The frame
|
||||
/// is counted lost instead, which is what fires the client's recovery request.
|
||||
#[test]
|
||||
fn slice_streamed_lying_base_within_bounds_kills_frame() {
|
||||
let (pkts, _) = slice_streamed_packets();
|
||||
let hdr_of = |p: &Vec<u8>| PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
|
||||
// Shift block 1's base from shard 19 (304 B) to shard 20 (320 B) on EVERY packet of the
|
||||
// block (the base is pinned by the block's first packet, so all must agree). Still
|
||||
// shard-aligned, still 20 + 26 = 46 ≤ 63 (the final block's base) — every pre-fix
|
||||
// check passes, and the frame would have completed with a one-shard zero gap at 19
|
||||
// and block 1's last shard overwriting block 2's first.
|
||||
let delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut h = hdr_of(p);
|
||||
if h.block_count == 0 && h.block_index == 1 {
|
||||
let mut p = p.clone();
|
||||
h.frame_bytes = 320;
|
||||
p[..HEADER_LEN].copy_from_slice(h.as_bytes());
|
||||
p
|
||||
} else {
|
||||
p.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cfg = slice_config();
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let stats = StatsCounters::default();
|
||||
assert!(
|
||||
push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none(),
|
||||
"a mis-tiled frame must never be delivered"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.snapshot().frames_dropped,
|
||||
1,
|
||||
"the mis-tiled frame must be counted lost"
|
||||
);
|
||||
assert_eq!(r.in_flight(), 0, "the killed frame must release its budget");
|
||||
|
||||
// Its packets are stragglers for a terminated index now — no resurrection, no recount.
|
||||
assert!(push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none());
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||
}
|
||||
|
||||
/// One slice bigger than a whole FEC block must cut MULTIPLE blocks from a single push (the
|
||||
/// flush loop) — the final block can never be left oversized.
|
||||
#[test]
|
||||
|
||||
@@ -931,7 +931,13 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
|
||||
/// an Automatic client (`0`) gets the codec's ~1.6 bpp operating point for the negotiated
|
||||
/// mode instead of the 20 Mbps H.26x default. The rate is then PINNED for the session:
|
||||
/// the client's ABR controller stays off for this codec and the host refuses mid-stream
|
||||
/// retargets. An explicit client rate is honored unchanged (the operator knows the link).
|
||||
/// retargets.
|
||||
///
|
||||
/// PyroWave ignores an explicit client rate too (ABR overhaul RFC §5.2): a fixed rate is
|
||||
/// ill-defined for an all-intra codec (bpp is the operating point, not kbps) and it used to
|
||||
/// bypass the `PUNKTFUNK_PYROWAVE_MAX_MBPS` operator ceiling. Clients grey the control out;
|
||||
/// this arm is the belt-and-braces for embedders that never update their UI. H.26x/AV1
|
||||
/// explicit rates are honored unchanged (the operator knows the link).
|
||||
fn resolve_bitrate_kbps_for(
|
||||
codec: crate::encode::Codec,
|
||||
requested: u32,
|
||||
@@ -939,7 +945,14 @@ fn resolve_bitrate_kbps_for(
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
bit_depth: u8,
|
||||
) -> u32 {
|
||||
if requested == 0 && codec == crate::encode::Codec::PyroWave {
|
||||
if codec == crate::encode::Codec::PyroWave {
|
||||
if requested != 0 {
|
||||
tracing::warn!(
|
||||
requested_kbps = requested,
|
||||
"an explicit bitrate is ill-defined under PyroWave (all-intra bpp semantics) — \
|
||||
treating it as Automatic and resolving the per-mode pin"
|
||||
);
|
||||
}
|
||||
// ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma
|
||||
// compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both
|
||||
// factors measured against the Phase-0 fixture matrix, design/pyrowave-444-hdr.md).
|
||||
@@ -976,7 +989,8 @@ fn resolve_bitrate_kbps_for(
|
||||
|
||||
/// Operator ceiling for PyroWave's open-loop Automatic bitrate pin: `PUNKTFUNK_PYROWAVE_MAX_MBPS`
|
||||
/// (megabits/s) → kbps, or `None` when unset/zero/invalid (no cap — the raw bpp pin stands).
|
||||
/// Only consulted for `requested == 0` PyroWave sessions; an explicit client bitrate bypasses it.
|
||||
/// Consulted for every PyroWave session — an explicit client bitrate resolves through the
|
||||
/// pin too (RFC §5.2), so nothing bypasses the ceiling.
|
||||
fn pyrowave_auto_pin_ceiling_kbps() -> Option<u32> {
|
||||
std::env::var("PUNKTFUNK_PYROWAVE_MAX_MBPS")
|
||||
.ok()
|
||||
@@ -2035,8 +2049,9 @@ async fn serve_session(
|
||||
});
|
||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||
// "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode
|
||||
// bpp pin the data plane re-resolves on a mid-stream mode switch.
|
||||
let bitrate_auto = hello.bitrate_kbps == 0;
|
||||
// bpp pin the data plane re-resolves on a mid-stream mode switch. PyroWave is Automatic
|
||||
// unconditionally (`resolve_bitrate_kbps_for` overrode any explicit rate — RFC §5.2).
|
||||
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
|
||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
|
||||
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
|
||||
@@ -2501,7 +2516,8 @@ mod tests {
|
||||
),
|
||||
(1920u64 * 1080 * 60 * 26 / 10 * 115 / 100 / 1000) as u32
|
||||
);
|
||||
// An explicit client rate is honored (clamped like any other codec)...
|
||||
// An explicit client rate is overridden to the same pin — a fixed kbps is ill-defined
|
||||
// for the all-intra codec, and it used to skip the operator ceiling (RFC §5.2)...
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
@@ -2510,7 +2526,7 @@ mod tests {
|
||||
ChromaFormat::Yuv420,
|
||||
8
|
||||
),
|
||||
130_000
|
||||
1920 * 1080 * 60 * 16 / 10 / 1000
|
||||
);
|
||||
// ...and the H.26x codecs keep the legacy default.
|
||||
assert_eq!(
|
||||
@@ -2559,10 +2575,11 @@ mod tests {
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 0, &small, ChromaFormat::Yuv420, 8),
|
||||
1920 * 1080 * 60 * 16 / 10 / 1000
|
||||
);
|
||||
// ...and an explicit client rate bypasses the ceiling entirely.
|
||||
// ...and an explicit client rate no longer bypasses it: PyroWave resolves through the
|
||||
// pin + ceiling whatever the Hello carried (RFC §5.2 — this bypass was the bug).
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 6_000_000, &mode, ChromaFormat::Yuv444, 10),
|
||||
6_000_000
|
||||
4_500_000
|
||||
);
|
||||
// SAFETY: as the set above — single writer, and the readers run on this thread.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS") };
|
||||
|
||||
@@ -1106,7 +1106,9 @@ pub(super) async fn negotiate(
|
||||
// "Automatic" — `bitrate_kbps` above is the host's own answer for `mode`, so the build
|
||||
// may re-resolve it if the source turns out to deliver a different size. Sampled here
|
||||
// rather than in the thread body so the closure doesn't have to capture `hello`.
|
||||
let bitrate_auto = hello.bitrate_kbps == 0;
|
||||
// PyroWave is Automatic unconditionally (`resolve_bitrate_kbps_for` overrode any
|
||||
// explicit rate — RFC §5.2).
|
||||
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
|
||||
let trace = bringup.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-stream".into())
|
||||
|
||||
@@ -1762,8 +1762,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
instead of building twice"
|
||||
);
|
||||
mode = m;
|
||||
// Mirror the loop's rebuild: PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin, so
|
||||
// a resolution change moves the operating point. Explicit client rates stay put.
|
||||
// Mirror the loop's rebuild: PyroWave's bitrate is a per-mode ~1.6 bpp pin, so a
|
||||
// resolution change moves the operating point (PyroWave is always Automatic — RFC §5.2).
|
||||
if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
bitrate_kbps =
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &mode, plan.chroma, plan.bit_depth);
|
||||
@@ -2512,10 +2512,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// new-mode frame — `build_pipeline` waits for it). Total lands in the shared
|
||||
// `resize_ms` slot (→ `session_status`); a failed rebuild abandons it silently.
|
||||
let resize_trace = crate::bringup::Trace::start("resize", resize_ms.clone());
|
||||
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
|
||||
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
||||
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
|
||||
// the link), and the H.26x codecs keep their mode-independent rate (ABR owns it).
|
||||
// PyroWave's bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — a
|
||||
// resolution change moves the operating point (1080p→4K quadruples the pixel rate),
|
||||
// so re-resolve it for the new mode (PyroWave is always Automatic — RFC §5.2). The
|
||||
// H.26x codecs keep their mode-independent rate (ABR owns it).
|
||||
let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth)
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -50,8 +50,10 @@ probe about two seconds in that lets the rate climb past 20 Mbps. An explicit ra
|
||||
session, clamped to **500 kbps – 8 Gbps**. A host card's menu has **Test network speed…** to suggest
|
||||
a value.
|
||||
|
||||
PyroWave has no useful low-rate regime: its Automatic is a fixed per-pixel budget for the negotiated
|
||||
mode (hundreds of Mbps), with adaptive bitrate and the probe off for the whole session.
|
||||
PyroWave is **always Automatic**: the rate is a fixed per-pixel budget for the negotiated mode
|
||||
(hundreds of Mbps), with adaptive bitrate and the probe off for the whole session. A fixed kbps
|
||||
is meaningless for the all-intra codec, so the bitrate setting is disabled while PyroWave is
|
||||
selected — your stored value is kept, and picking another codec restores it.
|
||||
|
||||
**Render scale** — *default: Native (1×).* The host renders and encodes at your mode times this;
|
||||
your device resamples to its window. Above 1× supersamples at more bandwidth and decode work; below
|
||||
@@ -298,7 +300,7 @@ exactly [what a profile can't change](/docs/profiles-and-links#what-a-profile-ca
|
||||
| You ask for | What the host does |
|
||||
|---|---|
|
||||
| Resolution and refresh | Builds a display at exactly that mode. A host pinned to a real monitor keeps that monitor's resolution and you scale locally. A size the encoder can't take — odd, or past the codec's per-axis limit — fails the connect rather than being quietly changed. |
|
||||
| A bitrate | Clamps it to 500 kbps – 8 Gbps, or uses its 20 Mbps default for Automatic (a per-pixel budget for Automatic PyroWave). |
|
||||
| A bitrate | Clamps it to 500 kbps – 8 Gbps, or uses its 20 Mbps default for Automatic. PyroWave ignores the number entirely — every PyroWave session gets the per-pixel budget. |
|
||||
| A codec | Honors it when it can encode it, else the best shared codec in the order HEVC → AV1 → H.264. |
|
||||
| 10-bit HDR | Upgrades only for HDR content on an encoder that can do 10-bit; otherwise 8-bit SDR. |
|
||||
| 4:4:4 chroma | Sends it only when every gate passes; otherwise 4:2:0. |
|
||||
|
||||
@@ -133,7 +133,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | Allow 10-bit (HEVC Main10 / AV1 10-bit) sessions at all; `0` forces every session to 8-bit SDR. Which hosts can actually deliver it, and the client half of the switch, are on [HDR](/docs/hdr). |
|
||||
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Host **policy gate** for full chroma 4:4:4 — sharper text and thin lines, no chroma loss. **On by default**; `0` forces every session to 4:2:0. It only ever *allows*: the client's own 4:4:4 setting (default off) is the real per-session switch, and the codec, capture-path and GPU gates behind it are on [Client settings → Full chroma](/docs/client-settings#video). Which GPUs and which clients can actually do it is in the [support matrix](/docs/support-matrix#encoders); how it interacts with HDR is on [HDR](/docs/hdr). **punktfunk/1 native only** — Moonlight stays 4:2:0. |
|
||||
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Applies to every PyroWave session — a client-requested bitrate is treated as Automatic under PyroWave, so nothing bypasses the ceiling. |
|
||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
|
||||
|
||||
@@ -29,8 +29,9 @@ For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic
|
||||
|
||||
The RPM repo has one group per Fedora release: **`fedora-44`** on Fedora 44, **`bazzite`** on
|
||||
Fedora 43 (it's a plain Fedora 43 build of the same package). `rpm -E %fedora` prints your number —
|
||||
set `baseurl` to match, then install. The browser console, `punktfunk-web`, comes along
|
||||
automatically:
|
||||
set `baseurl` to match, then install. The install line names the browser console
|
||||
(`punktfunk-web`) and the plugin runner (`punktfunk-scripting`) explicitly: they're *recommended*
|
||||
deps of `punktfunk`, and a box with `install_weak_deps=False` would silently skip them.
|
||||
|
||||
<Install platform="fedora" />
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
|
||||
> sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
> ```
|
||||
>
|
||||
> It also keeps the box's own power menu working: on a takeover flavour that *stops* the display
|
||||
> manager, the box has no active local session for the length of the stream, and Shut Down /
|
||||
> Restart / Sleep are refused unless you are in this group.
|
||||
>
|
||||
> Your package created the group at install time and put **nobody** in it, on purpose: it also
|
||||
> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present
|
||||
> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust.
|
||||
@@ -144,15 +148,17 @@ Whether the panel then goes *dark* is the **Topology** setting's job, not the mo
|
||||
|
||||
- **Managed** and **bare spawn** — the box's own gaming session is moved out of the way so it
|
||||
stops driving the panel, and then the panel is actually turned **off** for the stream and
|
||||
restored at teardown. On a KDE desktop that goes through KWin's DPMS; on a box already in Game
|
||||
Mode there is no KWin to ask, so the host turns the CRTCs off over DRM itself. Neither needs
|
||||
root — the DRM path rides the same seat access every local compositor gets.
|
||||
restored at teardown. The host asks whichever desktop is running: KDE goes through KWin's DPMS,
|
||||
sway and Hyprland through their own; a box already in Game Mode has no desktop to ask, so the
|
||||
host turns the CRTCs off over DRM itself. None of these needs root — the DRM path rides the same
|
||||
seat access every local compositor gets.
|
||||
- **Attach** — nothing is darkened, and cannot be: this model streams the panel the box is
|
||||
driving, so turning it off would turn off the picture.
|
||||
|
||||
Under `extend` or `primary` none of this happens and your screens are left alone. If `exclusive`
|
||||
asked for a dark screen and the host could not deliver one — a box already in Game Mode has no
|
||||
KDE desktop to ask for DPMS — it says so in the log rather than leaving you guessing at a lit
|
||||
asked for a dark screen and the host could not deliver one — **GNOME** is the case that cannot be
|
||||
served, because Mutter offers apps no way to turn a screen off and holds the graphics card itself
|
||||
so the DRM route is refused — it says so in the log rather than leaving you guessing at a lit
|
||||
screen.
|
||||
|
||||
Only the one head the session drives is listed — a nested or headless gamescope (including the
|
||||
|
||||
@@ -187,6 +187,24 @@ ffmpeg -hide_banner -encoders | grep nvenc # expect hevc_nvenc / av1_nvenc / h
|
||||
|
||||
The same applies on a layered Bazzite / Fedora Atomic install; the sysext image carries its own.
|
||||
|
||||
## `systemctl --user status punktfunk-web`: unit not found
|
||||
|
||||
The web console is its own package, and the `punktfunk` RPM only *recommends* it
|
||||
(`Recommends: punktfunk-web`) — a box with `install_weak_deps=False` in `/etc/dnf/dnf.conf`, a
|
||||
`--setopt=install_weak_deps=0` install, or an `rpm-ostree` layering that drops weak deps gets the
|
||||
host with no console and no unit to enable. Install it by name:
|
||||
|
||||
```sh
|
||||
rpm -q punktfunk-web || sudo dnf install punktfunk-web punktfunk-scripting
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
`No match for argument` instead means the repo you're on has no console: **COPR** builds host and
|
||||
client only (its mock chroot has no `bun`). Use the RPM registry —
|
||||
[Fedora](/docs/fedora#2-install-the-host), step 2. The same weak-dep miss happens on Debian/Ubuntu
|
||||
after an `apt install --no-install-recommends`; the fix is `sudo apt install punktfunk-web`.
|
||||
|
||||
## pacman: error: could not register 'punktfunk' database (database already registered)
|
||||
|
||||
The repo block got appended to `/etc/pacman.conf` twice — the add line is an append, so running it
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.31.3"
|
||||
"version": "0.31.4"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
This is a fix release about the machine you stream *from* behaving itself while you are not in the room with it. Asking for your screens off during a stream only ever worked on a box running a KDE desktop — a Steam Deck, or anything else sitting in Game Mode, kept its television lit for the whole session, playing your game to an empty room. That works now on every kind of Linux box, including ones with no desktop at all. Putting that box to sleep or shutting it down from Steam's own power menu had stopped working during a stream, in two separate ways, and works again. On the client side, a long session on a connection that was never the problem could slide from 57 Mbps down to 5 and stay there; the Mac app could quit outright the moment a stream started; and the web console served a certificate browsers refuse, so the page you are told to open did not load, while on Linux the tray insisted the console was dead the whole time it was running perfectly. There is a little new work too: the gamepad console's bitrate reaches 2 Gbps and takes a number you type, and on Linux there is finally a way into that console that is not a command line.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **"Turn my screens off while I stream" now actually turns them off.** It only ever worked on a KDE desktop. A Steam Deck or any other box in Game Mode ignored it silently, which is the deployment where you most want it. It works there, and on sway and Hyprland desktops too. GNOME is the one that still cannot be served, and the host now says so instead of leaving you guessing.
|
||||
- **Steam's power menu did nothing during a stream. Sleep, Restart and Shut Down all work again.** Two unrelated faults with one symptom: your host was refusing every suspend request for the length of a stream, including yours, and on some setups the box lost the permission to power itself off at all while it was streaming.
|
||||
- **A long session could slide to the worst picture quality on a perfectly good connection.** One 1440p session walked from 57 Mbps down to 5 over ten minutes without dropping a single packet, and never recovered. Both causes are fixed.
|
||||
- **Mac: the app could quit the instant a stream started.** Not at launch — at the moment you connected, which made it look like the host's fault.
|
||||
- **The web console would not open in a browser**, and on Linux the tray labelled it "not responding" while it was serving fine.
|
||||
- **New:** the gamepad console's bitrate goes to 2 Gbps, steps finely at the low end, and takes a typed number; on Linux the gamepad console gets a button, a menu entry and its own app-grid launcher.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **Only if your host takes the box over by stopping its login screen, and you want Steam's power menu to work:** be in the punktfunk group. That is the same group the takeover already needs to do its job, so if the takeover works for you today you are already in it and there is nothing to do. If you set that up some other way, join it — the docs page titled Steam / gamescope carries the one-line command — because the permission this release adds is granted to that group and nobody else, deliberately.
|
||||
|
||||
## New
|
||||
|
||||
- **Bitrate in the gamepad console reaches 2 Gbps, and you can type a rate.** The picker had seven rungs stopping at 80 Mbps since the console shipped, which someone ran straight into — and the desktop app beside it has always gone far higher, so the two disagreed about what your own machine was allowed to ask for. There are 30 rungs now, from 1 Mbps to 2 Gbps: tight at the bottom, where a single step decides whether a thin connection is watchable, and coarse at the top, where a step is noise. Press Y on that row to type a rate instead, using the same on-screen keyboard as the add-host and pairing fields, including Steam's own keyboard on a Deck. And a rate that is not one of the rungs — typed here, or set from the desktop app — now steps to its neighbour instead of being thrown away and snapped back to Automatic by a single nudge. The desktop app gets the other half of the same complaint: its bitrate box steps 1 Mbps at a time instead of 5, so 3, 4 and 6 are reachable without typing.
|
||||
- **Linux: a front door to the gamepad console.** The couch interface has shipped for a while, but on Linux the only way to reach it was a command-line switch, which is not something you find by looking. There are three ways in now: a gamepad button in the app's host list, a "Console UI" entry in its menu, and a separate launcher in your applications grid that you can also add to Steam as a non-Steam game so it opens straight from the couch. All three land in the same place. This comes with the packaged builds; a from-source Nix build still does not include the separate launcher, because that build leaves the console interface out.
|
||||
|
||||
## Improved
|
||||
|
||||
- **The adaptive bitrate control no longer gives up on a whole session.** Part of the picture-quality fix below: when your client decided the host's encoder was the bottleneck and stopped listening to that signal, it stopped listening for the rest of the session. But the thing that provokes it is temporary by nature — a game hammering the graphics card, a shader storm, a second program on the same card — and all of those end. It now starts listening again after a clean stretch, and if the noise comes straight back it backs off for longer each time rather than flapping.
|
||||
- **When your host cannot turn a screen off, it says so.** Every one of those refusals means "you asked for your screens off and they stayed on", and the most common one was being recorded at a level nobody reads. It is now a warning, with the reason.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Your screens stayed lit through the whole stream, on every Linux box except a KDE desktop.** Reported on Nobara: launching a game from the library with screens-off selected left the operator's monitor showing live Game Mode for the entire session. Three separate things were wrong. The setting was implemented by asking the desktop to turn its screens off — right when there *is* a desktop to ask, and a box in Game Mode has none, which is precisely the box whose television you want dark. That case is now handled by switching the display off through the graphics hardware directly, which needs no extra privileges and cannot leave a screen dark if the host crashes, because the screen comes back the moment the host lets go. A sway or Hyprland desktop was not served either, and now is, through each one's own controls, taking care to leave any screen it did not darken alone rather than "fixing" one that was already off. And separately, the part of the host that moves the box's own gaming session out of the way only did so for a Steam launch, because freeing Steam was the only reason anyone had ever needed it — so a non-Steam game from your library left that session running and driving the television regardless. Wanting your screens off is now a second, independent reason to move it aside. **GNOME remains a genuine gap, not an oversight:** it offers applications no way to turn a screen off, and holds the graphics card itself so the direct route is refused. The host warns instead of pretending.
|
||||
- **The recommended way of streaming a Game Mode box ignored the setting entirely.** The route that gives the best experience — and the only one that can serve your client its own screen at its own resolution — was the one route that never darkened anything, because there was nowhere safe to undo it and a screen left dark after every stream is worse than one left lit. The undo now lives in the single teardown every one of those sessions passes through, so it is honoured. A reconnect does not darken twice, and a session that keeps running between connections stays dark rather than flickering.
|
||||
- **Steam's power menu did nothing during a stream, and neither did Sleep.** Two independent faults, one symptom, both on Linux. Your host holds a lock for the length of a stream so the box cannot suspend out from under someone who is only watching and never touching anything — but that lock refuses *every* suspend, including the one you asked for by pressing Sleep, and nothing in Steam's interface shows the refusal, so the entry simply did nothing for as long as anyone was connected. The lock is now held only while the stream is genuinely quiet: any input from any client drops it immediately, so a Sleep press cannot lose the race, and it comes back after 30 seconds of silence. The case it was written for is unchanged — a passive viewer sends nothing, and the box still will not sleep under them. The second fault is Shut Down and Restart, on the takeover setups that stop the box's login screen: doing that leaves the box with no signed-in session, and the system's rules only allow powering off from one. All three entries quietly became a password prompt — asked of something that cannot answer, on a screen that is switched off. The packages now ship a rule granting exactly those three actions, to the punktfunk group and to nobody else, and only those three: a box with a second person logged in still asks first.
|
||||
- **A long session slid to the lowest picture quality on a connection that never lost a packet.** Reported from a 1440p60 session that ratcheted 57 Mbps down to 5 over ten minutes, with the host granting every rate it was asked for and nothing at all going wrong on the link. Two causes, both in the part of your client that watches how long the host is taking to compress each frame. Its thresholds were tuned on a 120-frames-a-second session, where one frame is 8 milliseconds; on a 60-frame session a frame is twice that, so an ordinary one-frame hiccup cleared the "something is badly wrong" bar that the identical hiccup at 120 never reached. They are now measured against the session's own frame budget. And the signal was firing for something cutting the rate cannot fix: it exists to find the point where the encoder runs out of compute, where less rate means less work — but when a game is saturating the graphics card that premise is false, the reduction changes nothing, and it would fire again a moment later. Two reductions in a row that buy no improvement now stand the signal down. Everything else your client watches — packet loss, delay, decoding, and the host's own refusal to climb — keeps its full authority throughout, so a real encoder limit is still caught.
|
||||
- **Mac: the app could quit outright the moment a stream started.** Not at launch, which is what made it look like a host problem. Setting up your microphone takes a snapshot of the input device's audio format and then hands that snapshot back a moment later, and on a Mac the input can change underneath in that window — you switch devices, something changes the sample rate, or the app itself switches to the device you chose two lines earlier. Any mismatch was fatal and could not be caught. It now asks the system to use whatever format the device actually has, so there is no snapshot to go stale, and follows the device if it changes rate mid-stream instead of resampling by the wrong ratio and pitch-shifting your voice.
|
||||
- **The web console would not load in a browser, and the tray called it dead.** A Linux operator saw "Open web console (not responding)" in the tray, beside a tooltip that said the host was idle — which was the clue, because that tooltip proved the host was answering perfectly on the very same check. Your host has kept two identities for a while now: a modern one that everything current uses, and an older one kept unchanged so Moonlight-compatible clients keep working. The web console never followed that split, and none of the things that start it could choose — so it served the old identity, which no browser will accept, and the page you were told to open failed to load. Meanwhile the tray was checking on the console using the *modern* identity, so its check was refused and a completely healthy console was labelled as not responding. The console now serves the modern identity whenever the host has one, and the tray's check no longer insists on any particular one, because it reads nothing, sends nothing, and decides only what a menu entry says. A host that predates the split, or one where you supplied your own certificate, is unaffected and behaves exactly as before.
|
||||
- **Hyprland: the request to turn a screen off is a toggle, and would have turned a dark screen back on.** Found on a live Hyprland box rather than in review, and worth saying because the first version of this fix was wrong in both directions: the command ignores whether you asked for on or off and simply flips whatever the screen is currently doing. So "turn it off now, turn it back on later" would have lit a screen that was already dark at the start of a stream, and darkened a lit one at the end — the operator's screen left off after streaming, which is the exact failure this whole feature exists to avoid. Your host now reads the screen's real state first, acts only if it differs, and checks afterwards that it worked. A screen already in the state you wanted is left completely alone.
|
||||
|
||||
## Thanks
|
||||
|
||||
Every fix above started as somebody saying exactly what they saw. The monitor still showing Game Mode through a whole stream, and which kind of game they had launched. The power menu that did nothing, on which box. The 1440p session whose quality walked downhill for ten minutes while every other number stayed flat — that report came with the measurements that separated the two causes, which is the hard half. The bitrate ceiling somebody hit and pointed out was lower than the app right next to it. The Mac that quit on connect. And the tray that said "not responding" beside a tooltip reading "idle" — noticing that those two things contradicted each other is what found the bug. Thank you.
|
||||
|
||||
One caveat we would rather write down than let you discover: the sway arm of the screens-off work has not been run against a real sway session — there is no sway machine in the lab. Hyprland, KDE, and the no-desktop route were all measured on real hardware. If you stream from sway, we would like to hear how it goes.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.4/CHANGELOG.md).
|
||||
|
||||
The short version: **nothing versioned moves, at all.** The streaming protocol, the embedding interface, the driver protocol, the gamepad channel, the add-on contract and the management API are exactly where 0.31.3 left them — the embedding header is byte-identical for the third release running, and the API description changes only its stamped version number. No header, package, plugin or integration needs rebuilding, re-pairing or re-publishing in any direction, and the add-on toolkit and TypeScript package are both untouched. Two behaviour changes are worth knowing about if you integrate: the host's sleep veto is now conditional on stream quiet rather than held for the whole session, and the client's encode-attributed backoff both re-arms after standing down and sizes its thresholds from the negotiated refresh rather than in absolute milliseconds.
|
||||
@@ -0,0 +1,3 @@
|
||||
• Fixes a long stream sliding down to the worst picture quality on a connection that was never the problem — and staying there. One session walked from 57 Mbps to 5 over ten minutes without losing a packet.
|
||||
• Bitrate now reaches 2 Gbps instead of stopping at 80 Mbps, and steps finely at the low end where one step decides whether a thin connection is watchable.
|
||||
• Press Y on the Bitrate row to type an exact rate. A rate that isn't a preset step no longer snaps back to Automatic.
|
||||
+1
-1
@@ -283,7 +283,7 @@ REPO
|
||||
CMD
|
||||
)"
|
||||
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
|
||||
run 'sudo dnf install punktfunk'
|
||||
run 'sudo dnf install punktfunk punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
sysext)
|
||||
install_line='sudo bash punktfunk-sysext.sh install'
|
||||
|
||||
Reference in New Issue
Block a user