Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f9c1f4d3 | ||
|
|
78efedc0d8 | ||
|
|
8c6099da2a | ||
|
|
2b066b3e11 | ||
|
|
c3c24b5855 | ||
|
|
0d4f878f32 | ||
|
|
d886cd0124 | ||
|
|
2832b5d0f6 | ||
|
|
6863f8141a | ||
|
|
cf4c12ea52 | ||
|
|
5e5d6904d3 | ||
|
|
9ce347e4c0 | ||
|
|
dea6395772 | ||
|
|
55dbb14cf4 | ||
|
|
f0b35de92a | ||
|
|
588962f696 | ||
|
|
91b8f1a939 |
@@ -56,7 +56,21 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
|
||||
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
|
||||
# newer head, so the older merge sha gets no run at all — its android change then sits on main
|
||||
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
|
||||
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
|
||||
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
|
||||
# ship that commit without inventing a filler push.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
|
||||
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
|
||||
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). The NDK clang targets get their own key universes automatically (keys embed
|
||||
@@ -228,7 +242,9 @@ jobs:
|
||||
# Single source of the version name + the Play track for the release steps below. versionCode
|
||||
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
|
||||
- name: Version + channel
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
@@ -250,7 +266,9 @@ jobs:
|
||||
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
working-directory: clients/android
|
||||
env:
|
||||
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
|
||||
@@ -285,7 +303,9 @@ jobs:
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
@@ -328,7 +348,9 @@ jobs:
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
if: >-
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
|
||||
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
|
||||
@@ -70,12 +70,18 @@
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
|
||||
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
|
||||
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
|
||||
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
|
||||
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
|
||||
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
|
||||
# predating it need an uninstall, not an upgrade.
|
||||
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
|
||||
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
|
||||
# would have to import into Trusted People before Windows will install the package.
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-client
|
||||
|
||||
+177
-1
@@ -12,7 +12,183 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.28.1
|
||||
## v0.29.0
|
||||
|
||||
53 commits since v0.28.1 (36 non-merge).
|
||||
|
||||
The headline contract change is one **additive** C ABI bump: the host now tells the client, in-band,
|
||||
where its management API lives, and the connection grew an accessor for it. The wire protocol, the
|
||||
driver protocol and the plugin contract do not move; every 0.28.x host, client, driver and plugin
|
||||
keeps interoperating with 0.29.0 in both directions, with no re-pairing. The one thing that needs an
|
||||
operator's hand is on Windows: the MSIX package identity changed with the move to a publicly
|
||||
trusted signing certificate, so that install path needs a one-time uninstall + reinstall.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.28.1 | v0.29.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged — `Welcome` grew a trailing field older peers never read (below) |
|
||||
| C ABI | 19 | **20** | one symbol added: `punktfunk_connection_mgmt_port` (below) |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.28.1 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.28.0 | **0.28.0** | the management API surface did not change; the file keeps the stamp it was regenerated under |
|
||||
| gamescope patch level (`+pfhdrN`) | 7 | **7** | unchanged — the patch series is untouched |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
- **C ABI 19 → 20, addition only.** `include/punktfunk_core.h` gains exactly one declaration,
|
||||
`punktfunk_connection_mgmt_port(const PunktfunkConnection *, uint16_t *)` — the management-API
|
||||
port the host advertised in its `Welcome`, or the documented default when it advertised none.
|
||||
Nothing is removed or reshaped; an embedder that compares `PUNKTFUNK_ABI_VERSION` at build time
|
||||
rebuilds against the new header and is done. Nothing in-tree compares it at runtime.
|
||||
- **The Windows MSIX package identity changed.** Releases are now signed by Azure Artifact Signing
|
||||
(below), and the MSIX manifest `Publisher` must equal the signer subject byte-for-byte — so it
|
||||
moved from the self-signed `CN=unom` to the verified subject. Package identity is Name +
|
||||
Publisher: Windows treats the new package as a different app, and an in-place upgrade is
|
||||
impossible by design. One-time uninstall + reinstall for MSIX installs; the `.exe` installer and
|
||||
winget-via-installer paths upgrade normally.
|
||||
- **Android embedder edge, additive:** `NativeBridge` gains `nativeHostMgmtPort`, and the native
|
||||
discovery record gains its 9th field, `mgmt` (the record's append-only rule; 0, non-numeric and
|
||||
out-of-range all parse as unknown). Out-of-tree JNI callers are unaffected unless they want the
|
||||
value.
|
||||
|
||||
### The management port is movable, survives, and is learned in-band
|
||||
|
||||
47990 is the management API's port and also the web-UI port of Sunshine and its forks — with the
|
||||
GameStream planes off, the only port the two still contend for. Moving it now actually works, end
|
||||
to end:
|
||||
|
||||
- **`PUNKTFUNK_MGMT_BIND` joins `host.env`** (the `PUNKTFUNK_GAMESTREAM` shape: env or CLI flag,
|
||||
the flag wins), so the choice survives package upgrades that rewrite the unit file. `serve`
|
||||
publishes the port it *actually bound* to `~/.config/punktfunk/mgmt-endpoint` (KEY=VALUE, written
|
||||
write-then-rename), and the console, the Windows service and the unit files all derive from that
|
||||
one file; the six hardcoded 47990 literals survive only as the old-host fallback.
|
||||
- **`Welcome.mgmt_port`** — a trailing `u16` after the cipher block, the same additive discipline
|
||||
as the eight fields before it, so `WIRE_VERSION` stays 2 and an older peer stops earlier and uses
|
||||
the default. ⚠ One encode subtlety, pinned by test: `cipher` used to be emitted only when
|
||||
non-default, and appending the port to an AES `Welcome` would land its low byte at offset 68 —
|
||||
exactly where every shipped 0.28.x client reads `cipher`, fail-closed. `encode` therefore writes
|
||||
an explicit cipher byte whenever a port rides along; a host advertising no port still emits
|
||||
exactly 68 bytes. The standalone `punktfunk1-host` binary advertises `0` (it has no management
|
||||
API).
|
||||
- **Clients persist it**: `KnownHost.mgmt_port` + `effective_mgmt_port()` across the Rust clients
|
||||
(three-rung: live advert → stored → default), the session console, Android (through
|
||||
`DiscoveredHost`), and Apple — where `StoredHost.mgmtPort` had existed all along but nothing ever
|
||||
wrote it, so every Apple client resolved 47990 regardless. A host that has never been seen over
|
||||
mDNS (VPN, routed subnet, multicast-dead network) now learns the port from the authenticated
|
||||
connection itself.
|
||||
- **`PUNKTFUNK_NATIVE_PORT`** completes the pair for the data plane — `--native-port` was CLI-only
|
||||
and died on upgrade. A bad value is a startup **error**, not a silent fall back to 9777.
|
||||
- The Windows shell's half of the client-side learn landed separately (#241): `trust.rs` re-exports
|
||||
`learn_mgmt_port`, the shell's own mDNS browser parses the `mgmt` TXT, and `HostTarget` carries
|
||||
the port like the mac client's target does.
|
||||
|
||||
### Linux thread priority: the renice was a no-op on every install to date
|
||||
|
||||
`boost_thread_priority`'s `setpriority()` needs `CAP_SYS_NICE` or a raised `RLIMIT_NICE`; no
|
||||
channel granted either, and the host binary can never carry a file capability (a capped process's
|
||||
`/proc/<pid>/exe` is unreadable to KWin — the 0.26.0-1 incident). So capture/encode/send ran at
|
||||
nice 0, and a shader-compile storm could deschedule them hard enough to stutter audio and drag ABR
|
||||
to its floor at zero loss. Now:
|
||||
|
||||
- **RealtimeKit fallback** — `MakeThreadHighPriorityWithPID`, the same unprivileged broker
|
||||
PipeWire clients use. Only the nice verb, never `MakeThreadRealtime`; nothing enters the
|
||||
permitted set, KWin identification is untouched.
|
||||
- **The audio plane is boosted at all, for the first time**: the 5 ms Opus
|
||||
capture→encode→send loop, the PipeWire capture mainloop, and the pad-audio streamer (on Windows
|
||||
too, via the existing `SetThreadPriority` arm).
|
||||
- **Packaging ships headroom for rtkit-less boxes**: `packaging/linux/50-punktfunk-nice.conf`
|
||||
(`user@.service.d`, `LimitNICE=-15` — a limit, not a grant; effective from next login) on rpm,
|
||||
Arch and deb, written to `/etc/systemd/system/user@.service.d` by the Steam Deck installer; deb
|
||||
and rpm gain a weak `Recommends: rtkit`, Arch an optdepends hint, and the NixOS module sets
|
||||
`security.rtkit.enable = mkDefault true`.
|
||||
|
||||
### Host capture gain works on `punktfunk/1`, and boosting no longer hard-clips
|
||||
|
||||
`PUNKTFUNK_AUDIO_GAIN` existed only on the GameStream plane, and where it applied it was a hard
|
||||
`clamp(-1.0, 1.0)` — flat-topping, so pushing past ~1.5× sounded broken long before it got loud
|
||||
(WASAPI loopback taps upstream of the endpoint's master volume, so the host's own slider never
|
||||
changes the sent level either). `punktfunk_core::audio::apply_gain` now serves **both planes** with
|
||||
a tanh soft knee above `SOFT_LIMIT_KNEE` (0.7, ≈−3.1 dBFS): C1-continuous, bounded by
|
||||
construction, odd-symmetric, memoryless (zero added latency). Unity is a no-op inside the function
|
||||
itself, so the default wire stays byte-for-byte identical. `capture_gain` rejects non-positive
|
||||
values and caps at 8.0 (+18 dB). This buys headroom, not loudness — it is deliberately not a
|
||||
compressor, and the docs say so. `SOFT_LIMIT_KNEE` is excluded from cbindgen on purpose.
|
||||
|
||||
### Windows binaries are signed by Azure Artifact Signing
|
||||
|
||||
Account `unomsigning`, profile `unom-io`, signed by a service principal holding only the
|
||||
profile-scoped signer role. Azure mints a **per-request leaf that expires in ~3 days**, which
|
||||
changes two rules: a timestamped countersignature is now *mandatory* (the old retry-without-
|
||||
timestamp fallback is a hard failure in Azure mode — it would ship an artifact that goes untrusted
|
||||
days later, everywhere at once), and leaf pinning is structurally impossible (the updater's
|
||||
`AUTHENTICODE_SHA256` note claiming otherwise is corrected). `pack-msix.ps1` reads the signature
|
||||
back off the packed `.msix` and fails on Publisher drift. Driver catalogs are deliberately
|
||||
untouched: they keep the `DRIVER_CERT_*` cert and the installer still plants it as a machine root
|
||||
(PnP trust is independent of SmartScreen/UAC trust). Canary and fork builds keep the `.pfx` and
|
||||
ephemeral fallbacks.
|
||||
|
||||
### Library: a launcher the host cannot open no longer costs the whole sync
|
||||
|
||||
`valid_launcher_ui` conflated vocabulary with environment. It is now split: `known_launcher_ui`
|
||||
(an unknown launcher kind is a plugin bug — still a hard 400) and `resolvable_launcher_ui` (the
|
||||
launcher just is not installed on this box — the entry is dropped with one warn and the games
|
||||
sync). Same shape as the unservable-cover fix, on the launch side. And Playnite is actually
|
||||
findable now: the old lookup read the LocalSystem service's own HKCU and `%LOCALAPPDATA%` (the
|
||||
SYSTEM profile — a per-user Playnite is invisible there) and matched a registry key name Inno Setup
|
||||
never writes. Now: every loaded hive under `HKEY_USERS` plus both HKLM views, matched on
|
||||
`DisplayName`, then `C:\Users\*\AppData\Local\Playnite`.
|
||||
|
||||
### Hyprland/sway capture: six defects, all ours, and streaming now survives past one session
|
||||
|
||||
The wlr portal route looked environmental and never was. Measured on Hyprland 0.55.4 +
|
||||
xdg-desktop-portal-hyprland 1.3.12, fixed in one arc (#240):
|
||||
|
||||
- **The dmabuf pod offered `BGRx`; xdph offers `BGRA`.** The modifier lists intersect perfectly,
|
||||
the fourcc never does, so PipeWire failed the link itself (`no more input formats`) — and the
|
||||
pods live only in the PipeWire *daemon's* log, which is why it read as a GPU/modifier problem.
|
||||
- **A per-cast tokio runtime orphaned ashpd's process-global D-Bus connection.** ashpd caches its
|
||||
connection in a `OnceLock`; the first cast's runtime hosted zbus's reader task and then died
|
||||
with the cast, so the first stream of a host process worked and every later one went black.
|
||||
Both wlr backends now share one long-lived portal runtime.
|
||||
- **Teardown removed the captured output before closing the cast**, and xdph spun on the wreckage;
|
||||
the order is now cast-then-output.
|
||||
- **A hung portal handshake leaked its thread** and the leak poisoned every later cast; the
|
||||
handshake is now bounded.
|
||||
- **The wlr absolute-motion injector aimed at the operator's head**, never the streamed one; the
|
||||
pointer is now bound to the streamed output.
|
||||
- **The cursor park schedule read a missing cursor overlay as a lost pointer** — an Embedded-mode
|
||||
portal never sends one.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **vdisplay/KDE:** a bare-spawn gamescope session under an exclusive topology now darkens the
|
||||
physical panels over `org_kde_kwin_dpms` (new in-process `kwin_dpms` module,
|
||||
`kscreen-doctor --dpms` fallback), refcounted host-wide so concurrent spawns compose; DPMS is
|
||||
non-persistent, so a dead host leaves nothing to journal. Managed and Attach routes untouched.
|
||||
- **macOS client:** `Settings::inhibit_shortcuts` is finally implemented on Apple — a local
|
||||
keyDown monitor claims every ⌘ chord while input is captured and forwards it host-side (AppKit
|
||||
dispatches menu key equivalents before the stream view sees them, so ⌘Q used to quit the
|
||||
client). ⌘⎋ and ⌃⌘F stay client-side; ⌘Tab/⌘Space/Mission Control are out of reach without a
|
||||
CGEventTap. Chord matching no longer compares Caps Lock and `.function`/`.numericPad` bits raw.
|
||||
- **Android client:** `Gamepad.padButtonBit` resolves a gamepad-sourced `KEYCODE_BACK` to
|
||||
`BTN_BACK` — pads that report Select as plain BACK (the Android-TV shape) no longer quit the
|
||||
stream on one press, and the Select chords (exit chord, mic mute, stats tier) become reachable
|
||||
on exactly those pads. `FLAG_FALLBACK` events stay excluded.
|
||||
- **CI:** Android canaries now feed Play **open testing (beta) and closed testing (alpha)** from
|
||||
one Play edit (`play-upload.py --also-track`); tags still publish production only, and a manual
|
||||
`android.yml` dispatch can now opt into publishing (`publish=true`), so a lost merge run is no
|
||||
longer a dead end. Windows
|
||||
runners provision the .NET 8 runtime and a machine-wide signing client (a mixed-mode dlib with
|
||||
no runtime makes signtool exit 3 in silence).
|
||||
|
||||
|
||||
|
||||
60 commits since v0.28.0.
|
||||
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
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.28.1"
|
||||
version = "0.29.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3031,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3120,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3134,11 +3134,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3167,14 +3167,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3189,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3197,7 +3197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3209,7 +3209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3218,7 +3218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3251,7 +3251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3262,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3273,7 +3273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3485,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3495,7 +3495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3513,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3530,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3544,7 +3544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3562,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3594,7 +3594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3603,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3673,7 +3673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3687,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3710,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -3,9 +3,12 @@ package io.unom.punktfunk.screenshots
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
@@ -708,39 +711,250 @@ private fun shotGames() = listOf(
|
||||
|
||||
private fun shotLibraryLoader(context: Context): ImageLoader {
|
||||
val engine = FakeImageLoaderEngine.Builder()
|
||||
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
|
||||
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
|
||||
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
|
||||
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
|
||||
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
|
||||
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
|
||||
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
|
||||
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
|
||||
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
|
||||
val w = 600
|
||||
val h = 900
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
|
||||
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
|
||||
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
|
||||
|
||||
private const val POSTER_W = 600
|
||||
private const val POSTER_H = 900
|
||||
|
||||
private fun posterY(v: Float) = POSTER_H - v
|
||||
|
||||
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
|
||||
private class ShotRand(var state: ULong) {
|
||||
fun next(): Float {
|
||||
state = state * 6364136223846793005UL + 1442695040888963407UL
|
||||
return (state shr 33).toFloat() / (1L shl 31).toFloat()
|
||||
}
|
||||
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
|
||||
}
|
||||
|
||||
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
|
||||
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
draw(canvas)
|
||||
posterTitle(canvas, title)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
|
||||
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
|
||||
canvas.drawRect(
|
||||
0f, 0f, w.toFloat(), h.toFloat(),
|
||||
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
|
||||
0f, POSTER_H.toFloat(), 0f, 0f,
|
||||
stops.map { it.second }.toIntArray(),
|
||||
stops.map { it.first }.toFloatArray(),
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
canvas.drawText(
|
||||
mark, w / 2f, h / 2f + 110f,
|
||||
}
|
||||
|
||||
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
|
||||
canvas.drawCircle(
|
||||
x, posterY(y), radius,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xD9FFFFFF.toInt()
|
||||
textSize = 320f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
shader = RadialGradient(
|
||||
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
|
||||
|
||||
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
|
||||
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
|
||||
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeWidth = width * mult
|
||||
this.color = shotAlpha(color, a)
|
||||
blendMode = BlendMode.SCREEN
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun posterTitle(canvas: Canvas, title: String) {
|
||||
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
|
||||
canvas.drawText(
|
||||
title, POSTER_W / 2f, posterY(72f),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = shotAlpha(0xFFFFFF, 0.94f)
|
||||
textSize = 46f
|
||||
letterSpacing = 5f / 46f
|
||||
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawAurora(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
|
||||
val rng = ShotRand(11UL)
|
||||
repeat(48) {
|
||||
val x = rng.range(0f, 600f)
|
||||
val y = rng.range(300f, 890f)
|
||||
val r = rng.range(1.4f, 3.2f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
|
||||
}
|
||||
data class Ribbon(
|
||||
val base: Float, val amp: Float, val freq: Float,
|
||||
val phase: Float, val w: Float, val c: Int,
|
||||
)
|
||||
for (r in listOf(
|
||||
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
|
||||
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
|
||||
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
|
||||
)) {
|
||||
val path = Path()
|
||||
for (i in 0..60) {
|
||||
val t = i / 60f
|
||||
val x = t * 600f
|
||||
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
|
||||
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
|
||||
}
|
||||
glowStroke(canvas, path, r.w, r.c)
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF191345.toInt(), 212f, 30f),
|
||||
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..9) {
|
||||
val x = i / 9f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStarfall(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
|
||||
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
|
||||
),
|
||||
)
|
||||
val rng = ShotRand(23UL)
|
||||
repeat(6) {
|
||||
val hx = rng.range(60f, 560f)
|
||||
val hy = rng.range(420f, 840f)
|
||||
val len = rng.range(90f, 170f)
|
||||
val dx = kotlin.math.cos(2.15f)
|
||||
val dy = kotlin.math.sin(2.15f)
|
||||
val path = Path()
|
||||
path.moveTo(hx, posterY(hy))
|
||||
path.lineTo(hx + dx * len, posterY(hy + dy * len))
|
||||
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
|
||||
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
|
||||
}
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF3A1430.toInt(), 300f, 26f),
|
||||
Triple(0xFF1D0818.toInt(), 216f, 34f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline))
|
||||
for (i in 1..8) {
|
||||
val x = i / 8f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawNeon(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
|
||||
val rng = ShotRand(7UL)
|
||||
val ring = Path().apply {
|
||||
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
|
||||
}
|
||||
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
|
||||
val gateX = listOf(-105f, 105f, 0f, 0f)
|
||||
val gateY = listOf(0f, 0f, -105f, 105f)
|
||||
for (i in 0 until 9) {
|
||||
var px: Float
|
||||
var py: Float
|
||||
if (i < 4) {
|
||||
px = 300f + gateX[i]
|
||||
py = 560f + gateY[i]
|
||||
} else {
|
||||
px = 40f * kotlin.math.round(rng.range(1f, 14f))
|
||||
py = 40f * kotlin.math.round(rng.range(1f, 21f))
|
||||
}
|
||||
val path = Path()
|
||||
path.moveTo(px, posterY(py))
|
||||
var horizontal = rng.next() > 0.5f
|
||||
repeat(rng.range(3f, 6f).toInt()) {
|
||||
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
|
||||
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
|
||||
path.lineTo(px, posterY(py))
|
||||
horizontal = !horizontal
|
||||
}
|
||||
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
|
||||
glowStroke(canvas, path, 5f, color)
|
||||
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEmber(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
|
||||
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
|
||||
),
|
||||
)
|
||||
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
|
||||
val rng = ShotRand(41UL)
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF5A2410.toInt(), 340f, 42f),
|
||||
Triple(0xFF401708.toInt(), 255f, 56f),
|
||||
Triple(0xFF200A04.toInt(), 165f, 48f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..10) {
|
||||
val x = i / 10f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
repeat(20) {
|
||||
val x = rng.range(30f, 570f)
|
||||
val y = rng.range(180f, 620f)
|
||||
val r = rng.range(2.5f, 6f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
let games: [GameEntry]
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
|
||||
@@ -74,7 +74,7 @@ struct LibraryView: View {
|
||||
@State private var errorText: String?
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
@State private var artLoader: (any LibraryArtSource)?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||
@@ -409,7 +409,7 @@ private struct LibraryBackCatcher: View {
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let artLoader: LibraryArtLoader?
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||
/// grid has no other way to say "Return launches THIS one".
|
||||
var selected = false
|
||||
|
||||
@@ -70,7 +70,7 @@ private extension Image {
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
let loader: (any LibraryArtSource)?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
|
||||
@@ -207,15 +207,20 @@ enum ShotMock {
|
||||
|
||||
/// A believable shelf for the library coverflow. Decoded rather than constructed:
|
||||
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
|
||||
/// construction surface. No art URLs — the posters render their deterministic fallback
|
||||
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
|
||||
/// construction surface. The `shot://art/…` posters are answered by [`ShotPosterArt.source`]
|
||||
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
|
||||
/// by design and renders its brand mark.
|
||||
static let games: [GameEntry] = {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
|
||||
"art": {"portrait": "shot://art/aurora"}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
|
||||
"art": {"portrait": "shot://art/starfall"}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
|
||||
"art": {"portrait": "shot://art/neon"}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
|
||||
"art": {"portrait": "shot://art/ember"}},
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
|
||||
"role": "launcher", "icon": "steam"}
|
||||
]
|
||||
@@ -263,12 +268,12 @@ private struct ShotHome: View {
|
||||
// MARK: - Library
|
||||
|
||||
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
|
||||
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
|
||||
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
|
||||
/// so the cards swing in already carrying posters (the entrance waits on art settling).
|
||||
private struct ShotLibrary: View {
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: nil,
|
||||
games: ShotMock.games, artLoader: ShotPosterArt.source,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
|
||||
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
|
||||
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
|
||||
// capture time — no bundled assets, nothing in a release build, and the same designs the Android
|
||||
// harness draws in Canvas, so the two listings show the same shelf.
|
||||
|
||||
#if DEBUG
|
||||
import CoreText
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PunktfunkKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
|
||||
/// the real coverflow in place of the paired-host loader.
|
||||
struct ShotArtSource: LibraryArtSource {
|
||||
let fixtures: [String: Data]
|
||||
|
||||
func data(for url: URL) async throws -> Data {
|
||||
guard let data = fixtures[url.absoluteString] else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func close() async {}
|
||||
}
|
||||
|
||||
enum ShotPosterArt {
|
||||
/// Art for `ShotMock.games` — keyed by the `shot://art/…` URLs those entries carry.
|
||||
static let source = ShotArtSource(fixtures: [
|
||||
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
|
||||
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
|
||||
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
|
||||
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
|
||||
])
|
||||
|
||||
private static let W = 600
|
||||
private static let H = 900
|
||||
|
||||
// MARK: - Canvas plumbing
|
||||
|
||||
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
|
||||
let space = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ctx = CGContext(
|
||||
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
draw(ctx)
|
||||
drawTitle(ctx, title)
|
||||
let image = ctx.makeImage()!
|
||||
let out = NSMutableData()
|
||||
let dest = CGImageDestinationCreateWithData(
|
||||
out, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
return out as Data
|
||||
}
|
||||
|
||||
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(
|
||||
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
|
||||
}
|
||||
|
||||
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
|
||||
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: stops.map(\.1) as CFArray,
|
||||
locations: stops.map(\.0))!
|
||||
ctx.drawLinearGradient(
|
||||
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
|
||||
}
|
||||
|
||||
private static func glowDot(
|
||||
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
|
||||
) {
|
||||
let clear = color.copy(alpha: 0)!
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
colors: [color, clear] as CFArray, locations: [0, 1])!
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0,
|
||||
endCenter: center, endRadius: radius, options: [])
|
||||
}
|
||||
|
||||
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend — the cheap
|
||||
/// neon-glow trick every one of these posters leans on.
|
||||
private static func glowStroke(
|
||||
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
|
||||
) {
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.screen)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.setLineJoin(.round)
|
||||
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
|
||||
ctx.addPath(path)
|
||||
ctx.setLineWidth(width * mult)
|
||||
ctx.setStrokeColor(color.copy(alpha: alpha)!)
|
||||
ctx.strokePath()
|
||||
}
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
private static func drawTitle(_ ctx: CGContext, _ title: String) {
|
||||
// A soft floor behind the caption keeps it legible over any art.
|
||||
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
|
||||
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
|
||||
let text = NSAttributedString(string: title, attributes: [
|
||||
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
|
||||
] as [NSAttributedString.Key: Any])
|
||||
let line = CTLineCreateWithAttributedString(text)
|
||||
let bounds = CTLineGetBoundsWithOptions(line, [])
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
|
||||
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
|
||||
CTLineDraw(line, ctx)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
/// Deterministic LCG so every capture draws the identical poster.
|
||||
private struct Rand {
|
||||
var state: UInt64
|
||||
mutating func next() -> CGFloat {
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
|
||||
}
|
||||
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
|
||||
}
|
||||
|
||||
// MARK: - The four posters
|
||||
|
||||
private static func drawAurora(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
|
||||
var rng = Rand(state: 11)
|
||||
for _ in 0..<48 {
|
||||
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
|
||||
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
|
||||
}
|
||||
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
|
||||
(700, 55, 1.15, 0.4, 30, 0x6656F2),
|
||||
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
|
||||
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
|
||||
]
|
||||
for r in ribbons {
|
||||
let path = CGMutablePath()
|
||||
for i in 0...60 {
|
||||
let t = CGFloat(i) / 60
|
||||
let p = CGPoint(
|
||||
x: t * 600,
|
||||
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
|
||||
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
|
||||
}
|
||||
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x191345), CGFloat(212), CGFloat(30)),
|
||||
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...9 {
|
||||
let x = CGFloat(i) / 9 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawStarfall(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
|
||||
var rng = Rand(state: 23)
|
||||
for _ in 0..<6 {
|
||||
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
|
||||
let len = rng.in_(90, 170)
|
||||
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° — up-left tails
|
||||
let path = CGMutablePath()
|
||||
path.move(to: head)
|
||||
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
|
||||
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
|
||||
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
|
||||
}
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
|
||||
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline))
|
||||
for i in 1...8 {
|
||||
let x = CGFloat(i) / 8 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawNeon(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
|
||||
var rng = Rand(state: 7)
|
||||
let ring = CGPath(
|
||||
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
|
||||
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
|
||||
for i in 0..<9 {
|
||||
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
|
||||
var p = i < 4
|
||||
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
|
||||
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
|
||||
let path = CGMutablePath()
|
||||
path.move(to: p)
|
||||
var horizontal = rng.next() > 0.5
|
||||
for _ in 0..<Int(rng.in_(3, 6)) {
|
||||
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
|
||||
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
|
||||
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
|
||||
path.addLine(to: p)
|
||||
horizontal.toggle()
|
||||
}
|
||||
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
|
||||
glowStroke(ctx, path, width: 5, color: color)
|
||||
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawEmber(_ ctx: CGContext) {
|
||||
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
|
||||
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
|
||||
var rng = Rand(state: 41)
|
||||
for (fill, baseline, rough) in [
|
||||
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
|
||||
(rgb(0x401708), CGFloat(255), CGFloat(56)),
|
||||
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
|
||||
] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
|
||||
for i in 1...10 {
|
||||
let x = CGFloat(i) / 10 * 600
|
||||
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: 600, y: 0))
|
||||
path.closeSubpath()
|
||||
ctx.setFillColor(fill)
|
||||
ctx.addPath(path)
|
||||
ctx.fillPath()
|
||||
}
|
||||
for _ in 0..<20 {
|
||||
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
|
||||
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -228,6 +228,16 @@ extension Artwork {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
|
||||
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
|
||||
/// artwork without a host on the network.
|
||||
public protocol LibraryArtSource: Sendable {
|
||||
func data(for url: URL) async throws -> Data
|
||||
/// Release pooled connections when the owning screen goes away. Sources without connections
|
||||
/// have nothing to do.
|
||||
func close() async
|
||||
}
|
||||
|
||||
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
|
||||
///
|
||||
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
|
||||
@@ -242,7 +252,7 @@ extension Artwork {
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
|
||||
private let address: String
|
||||
private let port: UInt16
|
||||
private let identity: SecIdentity
|
||||
|
||||
@@ -50,6 +50,7 @@ fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) ->
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
mgmt_port: None, // this shell resolves the library port itself (`mgmt_port_for`)
|
||||
},
|
||||
req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
// A plain card click carries no one-off: the resolver honors the host's own binding
|
||||
|
||||
@@ -12,7 +12,8 @@ x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-win
|
||||
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
|
||||
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
(`..._x64.msix` / `..._arm64.msix`, plus a matching `.cer` only in the fallback signing modes 2 and 3
|
||||
— Azure signing emits none); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
|
||||
|
||||
@@ -52,7 +53,8 @@ low-level input hooks, WASAPI and SDL3.
|
||||
MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
|
||||
the stable `latest/` alias and attached to the unified Gitea Release.
|
||||
- `main` push / `workflow_dispatch` → `0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
|
||||
- `main` push / `workflow_dispatch` → `X.<Y+1>.<run_number>.0` (canary — the minor *after* the latest
|
||||
`v*` tag, per `scripts/ci/pf-version.ps1`, climbing by run number; `canary/` alias).
|
||||
|
||||
## Signing & install
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ pub struct DiscoveredHost {
|
||||
/// `linux[/<family>][/<id>]`), sanitized — drives the host tile's OS mark and is
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
@@ -124,6 +129,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
os: pf_client_core::os::sanitize_os(&val("os")),
|
||||
mgmt_port: val("mgmt").parse().ok(),
|
||||
};
|
||||
if tx.send_blocking(host).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn spawn_session(
|
||||
fp_hex: Some(fp_hex.to_string()),
|
||||
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
|
||||
id: None,
|
||||
mgmt_port: None, // the library fetch runs in the shell (`Target`), never off a spawn plan
|
||||
},
|
||||
launch.map(str::to_string),
|
||||
profile.map(str::to_string),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -1450,16 +1450,21 @@ pub fn pipewire_thread(
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
// radeonsi/iHD import it and any compositor can allocate it.
|
||||
let mut modifiers = importer
|
||||
.as_mut()
|
||||
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||
.unwrap_or_default();
|
||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
// Modifiers our import stack handles, enumerated PER FOURCC. `XR24` (BGRx) and `AR24` (BGRA)
|
||||
// are asked separately on purpose: EGL/libva answer per format, and nothing entitles us to
|
||||
// assume a driver that imports one imports the other. Keeping them apart is also what makes
|
||||
// the BGRA pod below correct on AMD and Intel rather than an NVIDIA-shaped guess — each list
|
||||
// is whatever THIS GPU's stack actually said.
|
||||
//
|
||||
// To each list we add LINEAR (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's
|
||||
// only offer) import via CUDA external memory instead. For the VAAPI passthrough path there is
|
||||
// no importer at all, so the lists start empty and LINEAR is all we advertise: radeonsi/iHD
|
||||
// import it and any compositor can allocate it.
|
||||
let mut modifiers = Vec::new();
|
||||
let mut modifiers_bgra = Vec::new();
|
||||
if let Some(i) = importer.as_mut() {
|
||||
modifiers = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap());
|
||||
modifiers_bgra = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgra).unwrap());
|
||||
}
|
||||
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
|
||||
// advertisement with every modifier its device samples from, so compositors that
|
||||
@@ -1468,12 +1473,20 @@ pub fn pipewire_thread(
|
||||
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
|
||||
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
|
||||
// own (the emptiness check gates it).
|
||||
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !modifiers.contains(&m) {
|
||||
modifiers.push(m);
|
||||
let extend_pyrowave = vaapi_passthrough && !policy.pyrowave_modifiers.is_empty();
|
||||
for list in [&mut modifiers, &mut modifiers_bgra] {
|
||||
if (importer.is_some() || vaapi_passthrough) && !list.contains(&0) {
|
||||
list.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||
}
|
||||
if extend_pyrowave {
|
||||
for &m in &policy.pyrowave_modifiers {
|
||||
if !list.contains(&m) {
|
||||
list.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if extend_pyrowave {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
|
||||
@@ -1540,9 +1553,14 @@ pub fn pipewire_thread(
|
||||
);
|
||||
} else if want_dmabuf {
|
||||
tracing::info!(
|
||||
count = modifiers.len(),
|
||||
bgrx_count = modifiers.len(),
|
||||
bgra_count = modifiers_bgra.len(),
|
||||
// `sample` is TRUNCATED to 6, and LINEAR is pushed last — so reading the sample as the
|
||||
// whole list makes a perfectly good offer look tiled-only. That misreading cost a full
|
||||
// debugging session on 2026-08-14, hence stating the one bit that was actually wanted.
|
||||
linear_offered = modifiers.contains(&0),
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers (BGRx + BGRA pods)"
|
||||
);
|
||||
} else if consumer.cpu_is_downgrade() {
|
||||
// Reached only when no dmabuf is advertised at all (every arm above rules out a
|
||||
@@ -2094,17 +2112,39 @@ pub fn pipewire_thread(
|
||||
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if want_dmabuf {
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 3 } else { 2 });
|
||||
if prefer_native_nv12 {
|
||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
||||
}
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
if !modifiers.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
// xdph (Hyprland/sway) offers ONLY **BGRA** on its dmabuf EnumFormat — it lists BGRA *and*
|
||||
// BGRx on the SHM pod, so a BGRx-only dmabuf offer intersects with nothing and PipeWire
|
||||
// fails the link outright:
|
||||
// pw.link: negotiating -> error no more input formats (-22)
|
||||
// Measured 2026-08-14 on Hyprland 0.55.4 + xdph 1.3.12: the 12 tiled modifiers matched on
|
||||
// both sides perfectly — only the fourcc never did, which is why the failure reads like a
|
||||
// GPU/modifier problem and is not one.
|
||||
//
|
||||
// BGRA and BGRx are the same 32-bit layout; the alpha byte is ignored the whole way to the
|
||||
// encoder (`vk_util` maps both to `B8G8R8A8_UNORM`, VAAPI both to `Pixel::BGRA`), and the
|
||||
// dmabuf import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as
|
||||
// AR24. Listed AFTER BGRx so a producer offering both still lands on the pre-existing path
|
||||
// — first compatible consumer pod wins, so this is purely additive.
|
||||
if !modifiers_bgra.is_empty() {
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRA,
|
||||
&modifiers_bgra,
|
||||
preferred,
|
||||
)?);
|
||||
}
|
||||
pods
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
|
||||
@@ -38,6 +38,12 @@ pub struct HostTarget {
|
||||
pub fp_hex: Option<String>,
|
||||
pub mac: Vec<String>,
|
||||
pub id: Option<String>,
|
||||
/// The host's management-API port (saved store or live advert) — where the library is
|
||||
/// served, distinct from `port` (the native QUIC plane). Carried on the target for the same
|
||||
/// reason as `mac`: a front-end holding a plan has no `KnownHost` in hand, and resolving to
|
||||
/// [`crate::library::DEFAULT_MGMT_PORT`] there is what made a moved mgmt port work on the
|
||||
/// LAN but not over a VPN. `None` = unknown, fall back to the constant.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl From<&KnownHost> for HostTarget {
|
||||
@@ -49,6 +55,7 @@ impl From<&KnownHost> for HostTarget {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
mac: h.mac.clone(),
|
||||
id: h.id.clone(),
|
||||
mgmt_port: h.mgmt_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The compositor output absolute coordinates belong to, by NAME — the Linux counterpart of the
|
||||
//! Windows `stream_target` slot, and what the wlroots virtual-pointer backend aims at.
|
||||
//!
|
||||
//! `MouseMoveAbs` carries its own reference extent (`w`/`h` — the client's letterboxed video rect
|
||||
//! in ITS window, not the streamed mode), and the wlr protocol normalizes `x`/`y` against it and
|
||||
//! maps the result onto whichever `wl_output` the virtual pointer was **created with**. So the
|
||||
//! extent takes care of itself and the OUTPUT is the whole question. The injector used to pass the
|
||||
//! first `wl_output` the registry advertised, which is the oldest global — on any multi-head box
|
||||
//! the operator's physical head, never the per-session headless output the client is looking at.
|
||||
//! On the EXTEND backends (Hyprland, wlroots/sway) the streamed head sits *beside* the operator's,
|
||||
//! so absolute samples landed on a screen no session was streaming. Reported from the field as
|
||||
//! "no cursor was visible in the session", and later as a cursor pinned near the left edge that
|
||||
//! vanished part-way across.
|
||||
//!
|
||||
//! The host publishes the streamed output's compositor name at capture bring-up
|
||||
//! ([`set_stream_output`]) — Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`, or a mirrored head's
|
||||
//! connector — and the wlr backend re-creates its virtual pointer bound to the matching `wl_output`
|
||||
//! (`wl_output.name`, protocol v4; the name is explicitly "the same for all clients", so the name
|
||||
//! `hyprctl`/`swaymsg` minted is the name we can match here).
|
||||
//!
|
||||
//! **One slot per process**, exactly like the Windows original: the injector is host-lifetime and
|
||||
//! every concurrent session's input flows through it, so with parallel sessions the LAST capture
|
||||
//! bring-up wins for every session's absolute input. Per-session routing needs source-tagged input
|
||||
//! events (the injector has to become session-aware first — see [`crate::set_absolute_anchor`]'s
|
||||
//! note), and the single slot is never worse than what it replaces: today EVERY session's absolute
|
||||
//! input lands on a head that no session is streaming.
|
||||
//!
|
||||
//! With nothing published — before the first bring-up, or on a compositor whose `wl_output` is
|
||||
//! older than v4 and therefore nameless — the pointer is bound to NO output, which maps absolute
|
||||
//! coordinates over the whole layout. On a single-output compositor that is identical to binding
|
||||
//! that output; on a multi-head one it is at least *reachable*, unlike a pin to the wrong head.
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// The streamed output's compositor name, or `None` when nothing has been published yet.
|
||||
static STREAM_OUTPUT: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
/// Publish the compositor output (by name) that absolute input maps into. The host calls this at
|
||||
/// capture bring-up, and ONLY there: nothing clears it at teardown, because an output that goes
|
||||
/// away simply stops resolving (the backend falls back to whole-layout mapping, and between
|
||||
/// sessions nothing injects anyway). A later bring-up is what rewrites it — including to `None`,
|
||||
/// which a backend that needs no named binding passes so a stale name cannot outlive its
|
||||
/// compositor. See the module doc for the one-slot-per-process trade with parallel sessions.
|
||||
pub fn set_stream_output(name: Option<String>) {
|
||||
let mut cur = STREAM_OUTPUT.write().unwrap_or_else(|e| e.into_inner());
|
||||
if *cur != name {
|
||||
tracing::info!(output = ?name, "absolute-input stream output set");
|
||||
*cur = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamed output's compositor name, if one has been published.
|
||||
pub fn stream_output() -> Option<String> {
|
||||
STREAM_OUTPUT
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ONE test on purpose, like the libei anchor's: the slot is process-wide and cargo runs
|
||||
/// tests on threads in one process, so splitting this into several would let them race.
|
||||
#[test]
|
||||
fn publishes_clears_and_round_trips() {
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
// Re-publishing the same name is a no-op, not a second "set" (the backend keys its
|
||||
// pointer re-creation off the resolved name, but the log line should not repeat).
|
||||
set_stream_output(Some("PF-1643-1".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
|
||||
set_stream_output(Some("HEADLESS-2".into()));
|
||||
assert_eq!(stream_output().as_deref(), Some("HEADLESS-2"));
|
||||
set_stream_output(None);
|
||||
assert_eq!(stream_output(), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@
|
||||
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
|
||||
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
|
||||
//! state so the compositor resolves shifted keysyms correctly.
|
||||
//!
|
||||
//! **Absolute** motion is mapped by the compositor onto the `wl_output` the virtual pointer was
|
||||
//! CREATED with, so which output that is decides where every absolute sample lands. We aim it at
|
||||
//! the head the session is actually streaming — published by name in [`crate::stream_output`] and
|
||||
//! re-resolved (re-creating the pointer) whenever it changes; see [`WlrootsInjector::retarget`].
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -12,7 +17,12 @@ use punktfunk_core::input::InputKind;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, FromRawFd};
|
||||
use std::time::Instant;
|
||||
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
|
||||
use wayland_client::backend::WaylandError;
|
||||
use wayland_client::protocol::{
|
||||
wl_output::{self, WlOutput},
|
||||
wl_pointer, wl_registry,
|
||||
wl_seat::WlSeat,
|
||||
};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
@@ -27,13 +37,65 @@ use xkbcommon::xkb;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
struct Output {
|
||||
/// The registry global name — the key `wl_registry.global_remove` reports, and the user data
|
||||
/// each `wl_output` event carries back so we know which head it describes.
|
||||
global: u32,
|
||||
proxy: WlOutput,
|
||||
/// `wl_output.name` (protocol v4): the compositor's own name for the head — `HDMI-A-1`,
|
||||
/// Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`. The protocol guarantees this is "the same
|
||||
/// output name for all clients", which is what lets us match the name `hyprctl`/`swaymsg`
|
||||
/// minted on the vdisplay side. `None` on a compositor stuck at v3, which has no name event at
|
||||
/// all — then there is nothing to match on and the pointer stays unbound.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Globals bound from the registry (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct Globals {
|
||||
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
|
||||
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
output: Option<WlOutput>,
|
||||
/// EVERY advertised output, in advertisement order — not just the first. The streamed head is
|
||||
/// created per session, so it is never the first one advertised (that is the operator's
|
||||
/// oldest physical head), and binding only the first is what aimed absolute input at the
|
||||
/// wrong screen on every EXTEND box.
|
||||
outputs: Vec<Output>,
|
||||
}
|
||||
|
||||
/// Which advertised output — by position in `names`, which is advertisement order — the virtual
|
||||
/// pointer should bind to for the published target `want`.
|
||||
///
|
||||
/// The rule has **no fallback on purpose**, and that absence is the fix: what this replaced was a
|
||||
/// fallback ("bind whatever `wl_output` came first"), and the first-advertised output is the oldest
|
||||
/// global, i.e. the operator's physical head — never the per-session headless one the client is
|
||||
/// looking at. A target that matches nothing therefore yields `None`, which binds the pointer to no
|
||||
/// output and maps absolute coordinates over the whole layout: wrong-ish, but reachable, where a
|
||||
/// pin to the wrong head is unreachable.
|
||||
///
|
||||
/// Split out of [`Globals::output_named`] so the rule is testable — a `WlOutput` proxy cannot be
|
||||
/// constructed without a live Wayland connection, but the decision it feeds can.
|
||||
fn index_named<'a>(
|
||||
names: impl IntoIterator<Item = Option<&'a str>>,
|
||||
want: Option<&str>,
|
||||
) -> Option<usize> {
|
||||
let want = want?;
|
||||
names.into_iter().position(|n| n == Some(want))
|
||||
}
|
||||
|
||||
impl Globals {
|
||||
/// The `wl_output` whose compositor name is `want`, if it is currently advertised.
|
||||
fn output_named(&self, want: &str) -> Option<WlOutput> {
|
||||
index_named(self.outputs.iter().map(|o| o.name.as_deref()), Some(want))
|
||||
.map(|i| self.outputs[i].proxy.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
@@ -45,13 +107,12 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"zwlr_virtual_pointer_manager_v1" => {
|
||||
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
|
||||
}
|
||||
@@ -61,16 +122,52 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
"wl_output" if state.output.is_none() => {
|
||||
state.output = Some(registry.bind(name, version.min(3), qh, ()));
|
||||
"wl_output" => {
|
||||
// The `name` event is the only thing that tells the streamed head from the
|
||||
// operator's. Older compositors bind lower and stay nameless (harmless:
|
||||
// `output_named` then matches nothing and the pointer maps over the layout).
|
||||
// The registry global name rides along as user data so the events that follow
|
||||
// land on the right entry.
|
||||
let proxy = registry.bind(name, version.min(WL_OUTPUT_MAX), qh, name);
|
||||
state.outputs.push(Output {
|
||||
global: name,
|
||||
proxy,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// A head went away — a session's headless output being torn down is the common case,
|
||||
// and the pointer must stop being aimed at a dead object (`retarget` re-resolves and
|
||||
// falls back to the whole layout on the next absolute sample).
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| o.global != name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for Globals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the name matters here: geometry/mode/scale are the compositor's problem, because
|
||||
// binding the pointer to an output makes IT do the mapping (see `retarget`).
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) {
|
||||
o.name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The managers, the two virtual devices, the seat and the output emit no events we use.
|
||||
// The managers, the two virtual devices and the seat emit no events we use.
|
||||
macro_rules! ignore_events {
|
||||
($($t:ty),* $(,)?) => {$(
|
||||
impl Dispatch<$t, ()> for Globals {
|
||||
@@ -80,7 +177,6 @@ macro_rules! ignore_events {
|
||||
}
|
||||
ignore_events!(
|
||||
WlSeat,
|
||||
WlOutput,
|
||||
ZwlrVirtualPointerManagerV1,
|
||||
ZwlrVirtualPointerV1,
|
||||
ZwpVirtualKeyboardManagerV1,
|
||||
@@ -92,6 +188,14 @@ pub struct WlrootsInjector {
|
||||
queue: EventQueue<Globals>,
|
||||
globals: Globals,
|
||||
pointer: ZwlrVirtualPointerV1,
|
||||
/// The compositor name of the output `pointer` is bound to, or `None` when it is bound to no
|
||||
/// output (absolute coordinates then span the whole layout). Compared against
|
||||
/// [`crate::stream_output`] on every absolute sample; a difference re-creates the pointer.
|
||||
bound_output: Option<String>,
|
||||
/// evdev codes of the mouse buttons currently held on `pointer`, so re-creating the device
|
||||
/// can release them first — the compositor has no reason to, and a virtual pointer destroyed
|
||||
/// mid-press leaves the host with a stuck mouse button.
|
||||
pressed: Vec<u32>,
|
||||
keyboard: ZwpVirtualKeyboardV1,
|
||||
xkb_state: xkb::State,
|
||||
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
|
||||
@@ -100,6 +204,25 @@ pub struct WlrootsInjector {
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
/// Resolve the published stream output ([`crate::stream_output`]) against the outputs this
|
||||
/// connection has bound: `(proxy, name)` when the target is live, `(None, None)` otherwise.
|
||||
///
|
||||
/// `(None, None)` covers three cases that all want the same answer — nothing published yet (before
|
||||
/// the first capture bring-up), the target's `wl_output` global not advertised yet (the injector
|
||||
/// opens on the first input event, which can beat the session's display), and the target torn down
|
||||
/// (session end). A pointer bound to no output maps absolute coordinates over the whole layout,
|
||||
/// which on a single-output compositor is exactly that output and on a multi-head one at least
|
||||
/// keeps the streamed head reachable — unlike a pin to a head nobody is streaming.
|
||||
fn resolve_target(globals: &Globals) -> (Option<WlOutput>, Option<String>) {
|
||||
let Some(want) = crate::stream_output() else {
|
||||
return (None, None);
|
||||
};
|
||||
match globals.output_named(&want) {
|
||||
Some(proxy) => (Some(proxy), Some(want)),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on distinct characters the dynamic text keymap holds before it restarts from scratch
|
||||
/// (keycodes grow upward from 9; xkb tops out at 255, so stay well under).
|
||||
const TEXT_KEYMAP_MAX: usize = 200;
|
||||
@@ -140,12 +263,16 @@ impl WlrootsInjector {
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
|
||||
let pointer = pointer_mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
globals.output.as_ref(),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
// A second roundtrip: the first only said WHICH globals exist. The `wl_output.name` events
|
||||
// that identify each head are emitted on the objects we bound *during* that roundtrip, so
|
||||
// they only land now — and the pointer's output has to be resolved before we create it.
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("Wayland output-name roundtrip")?;
|
||||
|
||||
let (target, bound_output) = resolve_target(&globals);
|
||||
let pointer =
|
||||
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
|
||||
@@ -174,7 +301,9 @@ impl WlrootsInjector {
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!(
|
||||
output = globals.output.is_some(),
|
||||
outputs = globals.outputs.len(),
|
||||
want = ?crate::stream_output(),
|
||||
bound = ?bound_output,
|
||||
"wlroots virtual input ready (pointer + keyboard)"
|
||||
);
|
||||
Ok(Self {
|
||||
@@ -182,6 +311,8 @@ impl WlrootsInjector {
|
||||
queue,
|
||||
globals,
|
||||
pointer,
|
||||
bound_output,
|
||||
pressed: Vec::new(),
|
||||
keyboard,
|
||||
xkb_state,
|
||||
_keymap_file: file,
|
||||
@@ -190,6 +321,90 @@ impl WlrootsInjector {
|
||||
})
|
||||
}
|
||||
|
||||
/// Aim the virtual pointer at the output the session is streaming, re-creating it when that
|
||||
/// changes — the fix for absolute input landing on the operator's screen.
|
||||
///
|
||||
/// The wlr protocol maps `motion_absolute` onto the output the pointer was **created with**
|
||||
/// and offers no way to re-aim one, so a change means destroy + create. Cheap and rare: the
|
||||
/// host publishes the target once per capture bring-up, so a re-create fires at most a couple
|
||||
/// of times per session. The no-change path — every other absolute sample — costs one `RwLock`
|
||||
/// read and a scan of the output list, which has one entry per head.
|
||||
///
|
||||
/// Called from the `MouseMoveAbs` arm immediately BEFORE the motion is sent, so a re-created
|
||||
/// pointer gets its first position in the same batch rather than sitting wherever the
|
||||
/// compositor puts a brand-new device.
|
||||
///
|
||||
/// Resolution is by NAME, never by size: `MouseMoveAbs`'s extent is the client's letterboxed
|
||||
/// content rect in ITS window, not the streamed mode, so no size ladder could identify the
|
||||
/// head. Falling back to no output at all (whole-layout mapping) when the target is unknown is
|
||||
/// deliberate — see [`crate::stream_output`]'s module doc.
|
||||
fn retarget(&mut self) {
|
||||
let (target, want) = resolve_target(&self.globals);
|
||||
if want == self.bound_output {
|
||||
return;
|
||||
}
|
||||
let (Some(mgr), Some(seat)) = (self.globals.pointer_mgr.clone(), self.globals.seat.clone())
|
||||
else {
|
||||
return; // cannot re-create without the manager/seat; keep the pointer we have
|
||||
};
|
||||
// Never destroy a device with a button held: nothing else will release it.
|
||||
if !self.pressed.is_empty() {
|
||||
let t = self.now_ms();
|
||||
for btn in std::mem::take(&mut self.pressed) {
|
||||
self.pointer
|
||||
.button(t, btn, wl_pointer::ButtonState::Released);
|
||||
}
|
||||
self.pointer.frame();
|
||||
}
|
||||
self.pointer.destroy();
|
||||
self.pointer = mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
target.as_ref(),
|
||||
&self.queue.handle(),
|
||||
(),
|
||||
);
|
||||
tracing::info!(
|
||||
from = ?self.bound_output,
|
||||
to = ?want,
|
||||
"wlroots virtual pointer re-aimed (absolute input now maps into this output)"
|
||||
);
|
||||
self.bound_output = want;
|
||||
}
|
||||
|
||||
/// Drain the compositor's half of the connection, then push our batch to it — run after every
|
||||
/// injected event.
|
||||
///
|
||||
/// The **read** is the load-bearing half, and it used to be missing: `dispatch_pending`'s own
|
||||
/// documentation says it "will not perform reads on the Wayland socket", so the queue only
|
||||
/// ever held what [`Self::open`]'s roundtrips put there. Two consequences, both real. The
|
||||
/// injector could never learn about a `wl_output` created AFTER it opened — which is exactly
|
||||
/// the ordering the field report was captured in, and would have left [`Self::retarget`] with
|
||||
/// nothing to resolve. And everything the compositor sent us piled up unread in the socket
|
||||
/// buffer for the host's lifetime, including the protocol errors the code here claimed to be
|
||||
/// surfacing but structurally could not.
|
||||
///
|
||||
/// Non-blocking by construction: `read()` is documented to answer `WouldBlock` when the socket
|
||||
/// has nothing for us, which is the common case at input rates and is not an error.
|
||||
fn pump(&mut self) -> Result<()> {
|
||||
// `prepare_read` will not hand out a guard while events are still queued, so dispatch first.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
if let Some(guard) = self.conn.prepare_read() {
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch (post-read)")?;
|
||||
}
|
||||
Err(WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(e).context("wayland read"),
|
||||
}
|
||||
}
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u32 {
|
||||
self.start.elapsed().as_millis() as u32
|
||||
}
|
||||
@@ -271,6 +486,12 @@ impl InputInjector for WlrootsInjector {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w > 0 && h > 0 {
|
||||
// The compositor maps these onto the pointer's bound output, so make sure that
|
||||
// is the head this session streams before sending any. Checked here rather
|
||||
// than per inject: only absolute motion depends on the binding, and a pointer
|
||||
// swapped mid-drag is the one thing `retarget` has to work to be safe about.
|
||||
self.retarget();
|
||||
let t = self.now_ms(); // `retarget` may have consumed time releasing buttons
|
||||
let x = event.x.clamp(0, w as i32) as u32;
|
||||
let y = event.y.clamp(0, h as i32) as u32;
|
||||
self.pointer.motion_absolute(t, x, y, w, h);
|
||||
@@ -280,8 +501,12 @@ impl InputInjector for WlrootsInjector {
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = if event.kind == InputKind::MouseButtonDown {
|
||||
if !self.pressed.contains(&btn) {
|
||||
self.pressed.push(btn);
|
||||
}
|
||||
wl_pointer::ButtonState::Pressed
|
||||
} else {
|
||||
self.pressed.retain(|&b| b != btn);
|
||||
wl_pointer::ButtonState::Released
|
||||
};
|
||||
self.pointer.button(t, btn, st);
|
||||
@@ -328,12 +553,7 @@ impl InputInjector for WlrootsInjector {
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
self.pump()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,3 +603,40 @@ fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The live-box layout the field report came from: the operator's `HDMI-A-1` is advertised
|
||||
/// FIRST (it exists from compositor start), and the session's headless head is added later —
|
||||
/// so "first advertised" is always the wrong answer, whichever order the injector and the
|
||||
/// display happen to come up in.
|
||||
const HYPRLAND_BOX: [Option<&str>; 2] = [Some("HDMI-A-1"), Some("PF-87756-3")];
|
||||
|
||||
#[test]
|
||||
fn binds_the_streamed_head_not_the_first_advertised_one() {
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-3")), Some(1));
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("HDMI-A-1")), Some(0));
|
||||
// sway's own naming, and a mirrored physical head, resolve the same way.
|
||||
let sway = [Some("HEADLESS-1"), Some("DP-2"), Some("HEADLESS-2")];
|
||||
assert_eq!(index_named(sway, Some("HEADLESS-2")), Some(2));
|
||||
assert_eq!(index_named(sway, Some("DP-2")), Some(1));
|
||||
}
|
||||
|
||||
/// Every "we don't know" must land on NO output (whole-layout mapping), never on a guess —
|
||||
/// the regression this whole change exists to prevent.
|
||||
#[test]
|
||||
fn an_unknown_target_binds_nothing_rather_than_falling_back() {
|
||||
// Published but not advertised (yet, or any more — the injector opens on the first input
|
||||
// event, which can beat the display, and the head goes away at session end).
|
||||
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-9")), None);
|
||||
// Nothing published at all — before the first capture bring-up.
|
||||
assert_eq!(index_named(HYPRLAND_BOX, None), None);
|
||||
// A compositor older than wl_output v4 emits no `name` event, so nothing is matchable.
|
||||
assert_eq!(index_named([None, None], Some("PF-87756-3")), None);
|
||||
// …and a compositor advertising no outputs at all cannot resolve anything either.
|
||||
let headless: [Option<&str>; 0] = [];
|
||||
assert_eq!(index_named(headless, Some("PF-87756-3")), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::R
|
||||
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
|
||||
/// per-session anchor needs the injector to become session-aware first; don't call this from a
|
||||
/// session path until it is.
|
||||
///
|
||||
/// The wlroots backend does **not** consult this — it aims at a named output via
|
||||
/// `stream_output::set_stream_output` (Linux), which the host DOES publish per session and which
|
||||
/// therefore takes exactly the last-bring-up-wins trade this warning describes: on purpose, and
|
||||
/// stated in the open in that module's doc, matching the Windows `stream_target` slot that already
|
||||
/// made the same call. The two are separate slots because they answer different questions and are
|
||||
/// written by different owners: this anchor is the operator's host-wide capture pin, recomputed
|
||||
/// from policy whenever the console writes it — which would wipe a per-session value written here —
|
||||
/// while the stream output is whatever head the session's capture actually attached to.
|
||||
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
|
||||
let anchor = anchor.filter(|a| !a.is_empty());
|
||||
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
|
||||
@@ -529,6 +538,14 @@ pub mod pen;
|
||||
pub mod stream_target;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use stream_target::set_stream_target;
|
||||
/// Linux: the streamed compositor output (by name) that absolute coordinates map into — the
|
||||
/// counterpart of the Windows `stream_target` module, published by the host at capture bring-up and
|
||||
/// consumed by the wlroots virtual-pointer backend, which binds its pointer to that `wl_output`.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/stream_output.rs"]
|
||||
pub mod stream_output;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use stream_output::{set_stream_output, stream_output};
|
||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -79,6 +79,10 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
|
||||
#[path = "vdisplay/backend.rs"]
|
||||
pub(crate) mod backend;
|
||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
/// The NEGOTIATED ScreenCast cursor mode of a portal-backed output, reported per session by
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]. (The module itself stays private — the ladder that
|
||||
/// picks the mode is this crate's business; the verdict is the caller's.)
|
||||
pub use portal_cursor::Mode as PortalCursorMode;
|
||||
|
||||
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
|
||||
/// can wedge the calling (session) thread forever.
|
||||
@@ -833,6 +837,21 @@ mod portal_config;
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
/// The line fed to xdph's custom picker to select an output headlessly.
|
||||
///
|
||||
/// Declared unconditionally for the same reason again: it is a wire format with no schema and no
|
||||
/// error report, so the transcribed-parser tests are the only place a malformed line is visible
|
||||
/// without a compositor. That is not hypothetical — a missing separator shipped, and the one
|
||||
/// assertion that existed for it passed throughout.
|
||||
#[path = "vdisplay/linux/portal_picker.rs"]
|
||||
mod portal_picker;
|
||||
|
||||
/// The single, never-dropped tokio runtime the portal handshakes run on. Linux-only: it exists to
|
||||
/// outlive ashpd's process-global cached D-Bus connection, and only the Linux backends speak to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/portal_rt.rs"]
|
||||
mod portal_rt;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
|
||||
@@ -76,6 +76,20 @@ pub struct VirtualOutput {
|
||||
/// capturer must hold frames until that renegotiation lands. Linux-only.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub expect_exact_dims: bool,
|
||||
/// The compositor's own name for this output (Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`,
|
||||
/// a mirrored head's connector) — the Linux answer to what `win_capture` carries on Windows:
|
||||
/// the identity the host needs to aim **absolute input** at the head it is streaming
|
||||
/// (`pf_inject::set_stream_output`, called from `capture::capture_virtual_output`).
|
||||
///
|
||||
/// It is the `wl_output.name` of that head, which the protocol guarantees is the same string
|
||||
/// for every client — so the injector can match it on its own Wayland connection. `None` on
|
||||
/// the backends whose absolute mapping does not need it (KWin/Mutter inject through libei,
|
||||
/// which selects by region; gamescope owns its whole seat).
|
||||
///
|
||||
/// This crate must not depend on pf-inject (see the crate doc), so the name is only CARRIED
|
||||
/// here — the host publishes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub output_name: Option<String>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
@@ -101,6 +115,8 @@ impl VirtualOutput {
|
||||
pool_gen: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
expect_exact_dims: false,
|
||||
#[cfg(target_os = "linux")]
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +200,33 @@ pub trait VirtualDisplay: Send {
|
||||
fn hw_cursor(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The ScreenCast cursor mode the backend's portal actually NEGOTIATED for the most recent
|
||||
/// [`create`](Self::create) — the answer to [`set_hw_cursor`](Self::set_hw_cursor), which is
|
||||
/// only ever a *request*.
|
||||
///
|
||||
/// This is the difference between the two that matters downstream: on the whole wlr family
|
||||
/// (xdph, xdpw) `AvailableCursorModes` is `Hidden|Embedded`, so a session that asked for
|
||||
/// metadata is served **`Embedded`** — the compositor paints the pointer into the frames and
|
||||
/// sends no `SPA_META_Cursor`, ever, wherever the pointer is. A consumer that reads "no cursor
|
||||
/// overlay" as a symptom (the host's park schedule reads it as "the seat pointer has not
|
||||
/// reached the streamed output" — true on Mutter, which suppresses metadata while the pointer
|
||||
/// is off the recorded view) is then acting on noise; see
|
||||
/// [`PortalCursorMode::delivers_metadata`](crate::PortalCursorMode::delivers_metadata).
|
||||
///
|
||||
/// `None` — the default, and what every non-portal backend reports — means "nothing was
|
||||
/// negotiated through the xdg ScreenCast portal here, so this says nothing at all": KWin
|
||||
/// (`zkde_screencast` `pointer` mode), Mutter (`RecordVirtual` `cursor-mode`), gamescope (no
|
||||
/// pointer either way) and Windows (IddCx) all get exactly what they ask for through their own
|
||||
/// protocols, and their consumers must keep behaving as they always did. It is also `None`
|
||||
/// before the first `create`.
|
||||
///
|
||||
/// Reported by the wlr-family backends (`hyprland`, `wlroots`) and by the monitor
|
||||
/// [`mirror`](crate::open_mirror) when it delegates to one. Those outputs are never registry-
|
||||
/// pooled (`remote_fd.is_some()` — the portal fd cannot be re-opened per attach), so a reused
|
||||
/// kept display can never hand back a *stale* answer here.
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
None
|
||||
}
|
||||
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||
|
||||
@@ -525,6 +525,9 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
|
||||
@@ -718,6 +721,9 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
});
|
||||
}
|
||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||
@@ -834,6 +840,9 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// gamescope owns its own seat and injects through its EIS socket, not the wlr
|
||||
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
|
||||
output_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3595,6 +3604,9 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
// No xdg portal in this path at all (gamescope publishes the node itself), and no pointer
|
||||
// in the node either way — nothing to report.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
|
||||
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
|
||||
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
|
||||
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
|
||||
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
|
||||
//! `hyprctl output remove NAME`.
|
||||
//! installed shim that cats a per-session selection file we write right before the handshake —
|
||||
//! `[SELECTION]/screen:<NAME>`, whose leading `/` is xdph's mandatory empty-flags separator (see
|
||||
//! [`crate::portal_picker`], which owns the format and its tests).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `hyprctl output remove NAME`. Removing the output first is
|
||||
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
@@ -27,7 +29,8 @@
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:<name>` picker format (re-derived
|
||||
//! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the
|
||||
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
|
||||
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
|
||||
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
|
||||
@@ -44,7 +47,7 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-session file the xdph custom picker reads the selected output from. We write
|
||||
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
|
||||
/// [`picker_selection_line`] here right before the portal handshake selects sources. Lives under
|
||||
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
|
||||
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
|
||||
/// wlroots chooser file.
|
||||
@@ -61,13 +64,11 @@ fn picker_shim_path() -> String {
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
|
||||
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
|
||||
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
|
||||
/// "strange output" and falls back to the interactive picker. So a monitor selection is
|
||||
/// `[SELECTION]screen:<name>`.
|
||||
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
|
||||
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
|
||||
/// format with no error report and this file only compiles on Linux.
|
||||
fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
crate::portal_picker::selection_line(name)
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
|
||||
@@ -131,11 +132,18 @@ pub struct HyprlandDisplay {
|
||||
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
|
||||
/// remain the legs where the metadata channel is actually exercised.
|
||||
hw_cursor: bool,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl HyprlandDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(HyprlandDisplay { hw_cursor: false })
|
||||
Ok(HyprlandDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +209,10 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
@@ -224,16 +236,21 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
|
||||
// selection is one per-user file, so a concurrent session's write between ours and xdph's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: on today's xdph this is `embedded` whatever we
|
||||
// asked for, and the session's whole cursor behaviour follows from that fact rather than
|
||||
// from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -251,24 +268,100 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Hyprland is an EXTEND topology: this head sits BESIDE the operator's, so absolute
|
||||
// input has to be aimed at it by name or it lands on their screen. `hyprctl`'s monitor
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
|
||||
/// remove the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] remove the compositor output (fields drop
|
||||
/// in declaration order).
|
||||
///
|
||||
/// 🛑 THIS ORDERING USED TO BE A LIE. `StopGuard::drop` only set an atomic and returned, while the
|
||||
/// portal thread noticed it 200 ms later — so `OutputGuard::drop` ran `hyprctl output remove` on an
|
||||
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
|
||||
/// did to xdph.
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
|
||||
/// tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
|
||||
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
|
||||
/// wedge the host's teardown with it — every other blocking helper on this path is bounded the same
|
||||
/// way (see [`HYPRCTL_BUDGET`]).
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely remove the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT — "only the first stream after a portal start works" on Hyprland was
|
||||
/// this, root-caused 2026-08-14 against Hyprland 0.55.4 + xdph 1.3.12 + xdg-desktop-portal 1.20.4.
|
||||
///
|
||||
/// This used to be a bare `AtomicBool` that `drop` merely SET. The portal thread polled it every
|
||||
/// 200 ms and then just dropped its zbus connection, and xdph destroys a session on exactly one
|
||||
/// event — an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`,
|
||||
/// `onCloseSession`); it has no peer-vanished watcher of its own. The frontend does have one
|
||||
/// (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but it only fires once
|
||||
/// our unique bus name goes away, which is *after* the 200 ms poll, and it runs asynchronously on a
|
||||
/// GTask thread. Meanwhile `OutputGuard::drop` had already removed the output — synchronously,
|
||||
/// microseconds after the flag was set.
|
||||
///
|
||||
/// So every teardown destroyed the `wl_output` out from under a live screencopy session. xdph's next
|
||||
/// `Start` then built a PipeWire stream against that wreckage and fell into
|
||||
///
|
||||
/// ```text
|
||||
/// while (pSession->sharingData.nodeID == SPA_ID_INVALID) { // Screencopy.cpp:307-313
|
||||
/// int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); // timeout 0 = NON-blocking
|
||||
/// ```
|
||||
///
|
||||
/// — an unbounded hot spin on xdph's ONLY event-loop thread, inside the `Start` handler, holding its
|
||||
/// `m_mEventLock`. From that moment xdph answers no D-Bus, no Wayland and no PipeWire, ever again, and
|
||||
/// every later `select_and_cast` dies on our 20 s timeout. MEASURED on the box: the wedged instance's
|
||||
/// unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were
|
||||
/// exactly 232.7 s of wall clock between its last log flush and its restart — 231.971 s of CPU
|
||||
/// against 232.7 s of wall, i.e. one core pinned solid for precisely the wedged interval.
|
||||
///
|
||||
/// Waiting here closes that window: `Session.Close` is answered synchronously by the frontend
|
||||
/// (`xdp-session.c:217` `handle_close` → `xdp_session_close` →
|
||||
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
|
||||
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
|
||||
/// capturing.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session.
|
||||
///
|
||||
/// `None` on every path where no cast was ever established (a rejected or timed-out handshake):
|
||||
/// there is nothing to close, and a portal that just failed to answer for 20 s is precisely the
|
||||
/// one that would burn the whole budget here for nothing.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed — xdph has torn the capture down, the output is safe to remove.
|
||||
Ok(()) => {}
|
||||
// The thread is gone without confirming (it panicked, or the runtime died). Nothing is
|
||||
// holding the cast either way, so there is nothing left to wait for.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
// Still going after the budget. Fall through and remove the output anyway — a leaked
|
||||
// output is worse than a racy one — but say so, because this is the state that wedges
|
||||
// xdph and the next session will be the one that pays for it.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — removing the output underneath it, \
|
||||
which is what wedges xdph's frame loop; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +442,12 @@ impl Drop for OutputGuard {
|
||||
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
|
||||
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
|
||||
/// site already has a failed-query path.
|
||||
/// Ceiling on the whole ScreenCast handshake (`create_session` → `select_sources` → `start` →
|
||||
/// `open_pipe_wire_remote`). Deliberately under [`select_and_cast`]'s 20 s wait so a stuck portal is
|
||||
/// reported by the thread that owns it, with a reason, instead of the caller timing out on it — and,
|
||||
/// far more importantly, so that thread EXITS. See the note at the handshake itself.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
|
||||
@@ -401,19 +500,31 @@ impl Drop for SelectionFile {
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _sel_file = SelectionFile(sel);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is removed (see `StopGuard`). Kept a SEPARATE
|
||||
// channel from the setup one above — it fires at the other end of the cast's life, long after
|
||||
// `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -422,9 +533,14 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it. Only this
|
||||
// arm arms the wait: see the field note on `StopGuard::closed`.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -440,10 +556,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -793,7 +910,8 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
|
||||
/// self-owned per D1; unify if they ever diverge no further.)
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -801,16 +919,15 @@ fn portal_thread(
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// 🛑 The SHARED, never-dropped runtime — NOT a per-cast one. ashpd caches its D-Bus connection
|
||||
// process-globally, and a per-cast runtime takes that connection's background reader down with
|
||||
// it when the cast ends, leaving every later handshake in this process awaiting a reply nothing
|
||||
// is alive to read. That is the whole "the first stream works, the rest are black" bug. See
|
||||
// [`crate::portal_rt`] for the measurement.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -818,9 +935,21 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// Inside the bound below, deliberately: when the cached connection was orphaned this is
|
||||
// where the thread hung — `Screencast::new()` itself, before a single handshake call —
|
||||
// and a bound that started after it reported the caller's generic timeout instead.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
|
||||
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
|
||||
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
|
||||
@@ -829,51 +958,99 @@ fn portal_thread(
|
||||
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
|
||||
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// 🛑 BOUNDED, and that bound is load-bearing. `select_sources`/`start` await a D-Bus
|
||||
// reply a wedged portal never sends, and an await that never returns CANNOT be cancelled
|
||||
// by the `stop` flag — the thread never reaches the park loop that reads it. That is how
|
||||
// one host accumulated NINE live cast threads (28 tokio workers) on 2026-08-14: each
|
||||
// timed-out attempt left one behind holding a half-created portal session on this
|
||||
// process's shared D-Bus connection, and from the first hang onwards EVERY later request
|
||||
// from this process hung too — while a freshly-spawned process talking to the very same
|
||||
// portal completed the identical handshake fine. Shorter than the caller's 20 s wait, so
|
||||
// the failure is reported HERE with a reason instead of surfacing as a bare timeout.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
|
||||
// is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because the teardown now WAITS on what follows — every
|
||||
// millisecond here is a millisecond of stream teardown.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT GOES AWAY. Dropping the
|
||||
// connection and trusting the peer to notice is what this used to do, and it is not the
|
||||
// contract: xdph destroys a session only on an explicit
|
||||
// `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`). The caller is blocked
|
||||
// in `StopGuard::drop` waiting for the signal below, and only removes the compositor
|
||||
// output afterwards — that ordering is the whole fix; see `StopGuard`.
|
||||
//
|
||||
// Bounded: `close()` goes through xdg-desktop-portal to xdph, and an already-wedged xdph
|
||||
// never answers. Timing out here still signals, so teardown pays the budget once and
|
||||
// moves on rather than hanging on a portal that is already gone.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find xdph busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -929,9 +1106,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend hands the picker exactly what [`crate::portal_picker`] says — that module owns the
|
||||
/// format and its xdph-parser tests, which run on every platform rather than only this leg.
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
|
||||
fn picker_line_is_the_shared_selection_format() {
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,6 +1467,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session either: the `zkde_screencast` pointer mode was asked of KWin
|
||||
// directly and KWin honours it, so the request IS the answer.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(StopOnDrop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -602,6 +602,9 @@ pub(crate) fn stream_existing_output(
|
||||
node_id,
|
||||
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
|
||||
remote_fd: None,
|
||||
// Not an xdg-portal session: `cursor-mode` was set directly on `RecordMonitor` and Mutter
|
||||
// honours it, so the request IS the answer and there is nothing to report back.
|
||||
cursor_mode: None,
|
||||
keepalive: Box::new(guard),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
|
||||
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
|
||||
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
|
||||
///
|
||||
/// Public because the NEGOTIATED mode is a per-session fact the consumer needs: the host's stream
|
||||
/// loop reads it back off the backend ([`VirtualDisplay::last_portal_cursor_mode`]) to know whether
|
||||
/// `SPA_META_Cursor` can ever arrive on this output. Re-exported as
|
||||
/// [`crate::PortalCursorMode`](crate::PortalCursorMode).
|
||||
///
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`]: crate::VirtualDisplay::last_portal_cursor_mode
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
pub enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
@@ -52,7 +59,7 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
@@ -60,6 +67,20 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `SPA_META_Cursor` EVER arrive under this mode? Only under [`Metadata`](Mode::Metadata) —
|
||||
/// and this is the whole point of surfacing the negotiated mode.
|
||||
///
|
||||
/// Under `Embedded` the compositor paints the pointer into the frames and sends no cursor
|
||||
/// metadata **regardless of where the pointer is**, so on such a session the absence of a cursor
|
||||
/// overlay carries NO information: not about the pointer's position, not about whether the
|
||||
/// capture is healthy. Consumers that treat "no overlay" as a symptom (the host's seat-pointer
|
||||
/// park schedule, which reads it as "the pointer has not reached the streamed output" — true on
|
||||
/// Mutter, which suppresses metadata while the pointer is off the recorded view) must ask this
|
||||
/// first. Under `Hidden` there is no pointer at all, so the same holds.
|
||||
pub const fn delivers_metadata(self) -> bool {
|
||||
matches!(self, Mode::Metadata)
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
@@ -185,7 +206,7 @@ pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
pub(crate) fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
@@ -198,12 +219,17 @@ impl Mode {
|
||||
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
|
||||
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
|
||||
/// the mode that predates the property and that every implementation has always had.
|
||||
///
|
||||
/// Returns OUR [`Mode`], not ashpd's — the caller converts with [`Mode::to_ashpd`] for the request
|
||||
/// and carries the value out of the portal thread, because what was negotiated (as opposed to
|
||||
/// asked for) governs how the session's cursor behaves for its whole life. See
|
||||
/// [`Mode::delivers_metadata`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
) -> Mode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
@@ -216,7 +242,7 @@ pub(crate) async fn negotiate(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
return Mode::Embedded;
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
@@ -238,7 +264,7 @@ pub(crate) async fn negotiate(
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
choice.mode
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -286,6 +312,21 @@ mod tests {
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The consumer-facing half of the same incident: xdph negotiates `3` down to `Embedded`, and
|
||||
/// under Embedded no `SPA_META_Cursor` ever arrives — so a host that reads "no cursor overlay"
|
||||
/// as "the pointer has not reached the streamed output" (true on Mutter, which suppresses
|
||||
/// metadata off-view) re-centres the user's pointer forever. Field report 2026-08-14: the seat
|
||||
/// pointer warped to centre once a second for the full park cap on a working Hyprland stream.
|
||||
#[test]
|
||||
fn only_metadata_can_deliver_a_cursor_overlay() {
|
||||
assert!(Mode::Metadata.delivers_metadata());
|
||||
assert!(!Mode::Embedded.delivers_metadata());
|
||||
assert!(!Mode::Hidden.delivers_metadata());
|
||||
// The negotiated mode is what governs, not the wanted one: this is the exact ladder result
|
||||
// on xdph/xdpw, and it says "no overlay is ever coming" even though metadata was requested.
|
||||
assert!(!pick(3, Mode::Metadata).mode.delivers_metadata());
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! The line we feed xdg-desktop-portal-hyprland's **custom picker** to select an output headlessly.
|
||||
//!
|
||||
//! xdph has no headless source-selection API: it runs `screencopy:custom_picker_binary` and parses
|
||||
//! one line from its stdout. The Hyprland backend points that at a shim which cats a per-session
|
||||
//! file, and this module is the format of what goes in the file — a wire format with no schema, no
|
||||
//! validation and no error report, whose only observable failure is a line in the portal's log.
|
||||
//!
|
||||
//! Declared unconditionally although only `hyprland.rs` calls it, for the reason `portal_config` and
|
||||
//! `portal_cursor` give: this is pure string handling whose tests are the only place its behaviour
|
||||
//! is checkable without a compositor in front of you, so they run on every platform's CI rather than
|
||||
//! only on the leg that compiles `mod hyprland`. That is not hypothetical here — the bug below
|
||||
//! shipped, and the one test that existed for it passed the whole time.
|
||||
|
||||
/// The picker line selecting monitor `name`: `[SELECTION]<flags>/<selection>`, with **empty flags**.
|
||||
///
|
||||
/// 🛑 THE `/` IS MANDATORY AND WE USED TO OMIT IT. xdph splits the line on the FIRST `/` into flags
|
||||
/// and selection ([xdph 1.3.12] `src/shared/ScreencopyShared.cpp:86-87`):
|
||||
///
|
||||
/// ```text
|
||||
/// const auto FLAGS = SELECTION.substr(0, SELECTION.find_first_of('/'));
|
||||
/// const auto SEL = SELECTION.substr(SELECTION.find_first_of('/') + 1);
|
||||
/// ```
|
||||
///
|
||||
/// With no `/` anywhere, `find_first_of` returns `npos`, so `FLAGS` becomes the WHOLE payload — and
|
||||
/// `SEL` becomes the whole payload too, purely because `npos + 1` wraps to `0`. The output name
|
||||
/// therefore still parsed correctly, which is exactly why this survived: the only thing it broke was
|
||||
/// the flag loop (`:89-94`), which then walked `screen:<name>` one character at a time —
|
||||
///
|
||||
/// ```text
|
||||
/// [screencopy] unknown flag from share-picker: s
|
||||
/// [screencopy] unknown flag from share-picker: c
|
||||
/// [screencopy] unknown flag from share-picker: e … one line per character
|
||||
/// ```
|
||||
///
|
||||
/// — and, because `sc*r*een` contains an `r`, which is xdph's "allow restore token" flag, set
|
||||
/// `data.allowToken = true`. xdph then answered every `Start` with a `restore_data` +
|
||||
/// `persist_mode: 2` we never asked for (we request `PersistMode::DoNot`), which is the
|
||||
/// `[screencopy] Sent restore token to …` on every single session in the field log.
|
||||
///
|
||||
/// The reference picker prints the separator unconditionally
|
||||
/// (`hyprland-share-picker/main.cpp:133-136`):
|
||||
///
|
||||
/// ```text
|
||||
/// std::cout << "[SELECTION]";
|
||||
/// std::cout << (ALLOWTOKENBUTTON->isChecked() ? "r" : "");
|
||||
/// std::cout << "/";
|
||||
/// std::cout << "screen:" << outputName.toStdString() << "\n";
|
||||
/// ```
|
||||
///
|
||||
/// so empty flags are spelled as a bare leading `/`, not as nothing at all.
|
||||
///
|
||||
/// ⚠️ This was NOT the cause of the "only the first stream works" stall — see `hyprland.rs`'s
|
||||
/// `StopGuard` for that. The sessions that streamed fine logged the identical flag spam and the
|
||||
/// identical restore token, so it never discriminated. It is a real bug on its own terms and nothing
|
||||
/// more.
|
||||
///
|
||||
/// The trailing newline is equally load-bearing: xdph does `data.output.pop_back()` unconditionally
|
||||
/// after `SEL.substr(7)` (`:96-100`), so without it the last character of the output name is eaten.
|
||||
///
|
||||
/// [xdph 1.3.12]: https://github.com/hyprwm/xdg-desktop-portal-hyprland/blob/v1.3.12/src/shared/ScreencopyShared.cpp
|
||||
pub(crate) fn selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]/screen:{name}\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// xdph 1.3.12's parser (`ScreencopyShared.cpp:82-100`), transcribed — including the `npos`
|
||||
/// arithmetic, which is the entire subtlety. Returns `(flags, output)`, or `None` where xdph
|
||||
/// would fall through to its interactive picker.
|
||||
///
|
||||
/// Transcribed rather than asserted on the string, because the bug this catches is invisible in
|
||||
/// the string: the old line yielded the RIGHT OUTPUT NAME while handing xdph the whole selection
|
||||
/// as a FLAG STRING. Only running its parser tells the two apart.
|
||||
fn xdph_parse(picker_stdout: &str) -> Option<(String, String)> {
|
||||
// `if (!RETVAL.contains("[SELECTION]")) return data;` — a default `SSelectionData`, i.e.
|
||||
// TYPE_INVALID, which makes `SelectSources` fail.
|
||||
let marker = picker_stdout.find("[SELECTION]")?;
|
||||
let selection = &picker_stdout[marker + "[SELECTION]".len()..];
|
||||
// `substr(0, npos)` is the whole string, and `substr(npos + 1)` is `substr(0)` — also the
|
||||
// whole string. Unsigned wraparound, not a special case in xdph.
|
||||
let (flags, sel) = match selection.find('/') {
|
||||
Some(i) => (&selection[..i], &selection[i + 1..]),
|
||||
None => (selection, selection),
|
||||
};
|
||||
let name = sel.strip_prefix("screen:")?;
|
||||
// `data.output.pop_back()` — unconditional, hence the mandatory trailing newline.
|
||||
let mut output = name.to_string();
|
||||
output.pop();
|
||||
Some((flags.to_string(), output))
|
||||
}
|
||||
|
||||
/// The three load-bearing parts of the line, pinned as bytes.
|
||||
#[test]
|
||||
fn the_line_carries_marker_empty_flags_separator_and_newline() {
|
||||
assert_eq!(selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
|
||||
/// What xdph actually makes of our line: the exact output, and NO flags.
|
||||
#[test]
|
||||
fn xdph_reads_our_line_as_an_output_with_no_flags() {
|
||||
for name in ["PF-1", "PF-1620-1", "HDMI-A-1", "DP-2"] {
|
||||
let (flags, output) = xdph_parse(&selection_line(name)).expect("xdph parses our line");
|
||||
assert_eq!(output, name, "xdph must recover the exact output name");
|
||||
assert_eq!(flags, "", "we ask for no flags at all");
|
||||
assert!(
|
||||
!flags.contains('r'),
|
||||
"an `r` in the flags makes xdph hand back restore_data + persist_mode=2 we never \
|
||||
requested (Screencopy.cpp:261-267)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression itself, so it cannot come back by "simplifying" the leading `/` away: the line
|
||||
/// we used to send parsed the whole selection as flags, `r` included.
|
||||
#[test]
|
||||
fn without_the_separator_the_whole_selection_becomes_flags() {
|
||||
let (flags, output) = xdph_parse("[SELECTION]screen:PF-1620-1\n").expect("still parses");
|
||||
assert_eq!(
|
||||
output, "PF-1620-1",
|
||||
"the output name did survive — which is precisely why this hid for so long"
|
||||
);
|
||||
assert_eq!(
|
||||
flags, "screen:PF-1620-1\n",
|
||||
"…while the entire selection was handed to the flag loop"
|
||||
);
|
||||
assert!(
|
||||
flags.contains('r'),
|
||||
"the `r` of `sc*r*een` is xdph's allow-restore-token flag"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without the trailing newline xdph's unconditional `pop_back()` eats a character of the name —
|
||||
/// a silently wrong output, not an error.
|
||||
#[test]
|
||||
fn the_trailing_newline_is_what_pop_back_consumes() {
|
||||
assert!(selection_line("PF-1620-1").ends_with('\n'));
|
||||
let (_, truncated) = xdph_parse("[SELECTION]/screen:PF-1620-1").expect("parses");
|
||||
assert_eq!(
|
||||
truncated, "PF-1620-",
|
||||
"pop_back() takes the last real character"
|
||||
);
|
||||
}
|
||||
|
||||
/// A line with no marker at all is xdph's documented empty-read fallback: it prompts instead. The
|
||||
/// shim relies on this when no session has written the selection file.
|
||||
#[test]
|
||||
fn an_empty_read_is_not_a_selection() {
|
||||
assert!(xdph_parse("").is_none());
|
||||
assert!(xdph_parse("screen:PF-1\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The ONE tokio runtime every portal handshake runs on, for the life of the process.
|
||||
//!
|
||||
//! 🛑🛑🛑 This exists because of a lifetime bug that cost a full day of misdiagnosis, so the reason
|
||||
//! is written down rather than left to be rediscovered.
|
||||
//!
|
||||
//! ashpd caches its D-Bus connection **process-globally** — `static SESSION: OnceLock<Connection>`
|
||||
//! (ashpd 0.13.13, `src/proxy.rs:27`). The first `Screencast::new()` in the process creates that
|
||||
//! connection, and zbus spawns the connection's background reader as a task **on whichever tokio
|
||||
//! runtime happens to be current at that moment**.
|
||||
//!
|
||||
//! Each backend used to build its own multi-thread runtime per cast and drop it at teardown. So the
|
||||
//! FIRST cast of a host process created the cached connection on a runtime that was then destroyed
|
||||
//! when that cast ended — and the `OnceLock` went on handing the same, now-executor-less connection
|
||||
//! to every later `Screencast::new()`, which then awaited a reply nothing was left alive to read.
|
||||
//!
|
||||
//! MEASURED 2026-08-14 (Hyprland 0.55.4 + xdph 1.3.12): the first cast of a host process streamed;
|
||||
//! every cast after it hung, in a process whose surviving cast thread sat in `futex_do_wait` inside
|
||||
//! runtime shutdown. The discriminator that pins it on us rather than on the compositor stack: a
|
||||
//! freshly spawned process completed the identical handshake against the identical xdph, repeatedly,
|
||||
//! while the long-lived host could complete none — and xdph itself was idle (28 ms of CPU).
|
||||
//!
|
||||
//! ⚠ Therefore: **never build a per-cast runtime, and never drop this one.** A `OnceLock` that is
|
||||
//! only ever read keeps the connection's reader alive for the process lifetime, which is exactly as
|
||||
//! long as the cached connection itself lives. `block_on` takes `&self`, so every cast thread can
|
||||
//! park on this one runtime concurrently.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
/// Build failures are reported to the caller rather than panicking: a host that cannot build a
|
||||
/// runtime should fail the cast with a reason, not abort the process.
|
||||
static PORTAL_RT: OnceLock<std::io::Result<Runtime>> = OnceLock::new();
|
||||
|
||||
/// The shared portal runtime, or the error from trying to build it.
|
||||
///
|
||||
/// Multi-thread with 2 workers: the zbus background reader must be pumped *across* the
|
||||
/// `create_session` → `select_sources` → `start` handshake while a cast thread blocks on it, which a
|
||||
/// current-thread runtime cannot do.
|
||||
pub(crate) fn portal_runtime() -> Result<&'static Runtime, String> {
|
||||
match PORTAL_RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("punktfunk-portal-rt")
|
||||
.enable_all()
|
||||
.build()
|
||||
}) {
|
||||
Ok(rt) => Ok(rt),
|
||||
Err(e) => Err(format!("build the shared portal runtime: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
|
||||
//! to confirm it, and only then runs `swaymsg output <NAME> unplug` (headless outputs support
|
||||
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
|
||||
//! copy, which is where this was measured.
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
@@ -67,11 +69,18 @@ pub struct WlrootsDisplay {
|
||||
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
|
||||
/// cancel the cast and hand the client a black screen.
|
||||
hw_cursor: bool,
|
||||
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl WlrootsDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(WlrootsDisplay { hw_cursor: false })
|
||||
Ok(WlrootsDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +109,10 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
warn_topology_is_extend_only();
|
||||
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
|
||||
@@ -146,16 +159,21 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
|
||||
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let (fd, node_id, cursor_mode, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
// Latched for `last_portal_cursor_mode`: xdpw refuses metadata by construction, so this is
|
||||
// `embedded` whatever we asked for, and the session's whole cursor behaviour follows from
|
||||
// that fact rather than from `hw_cursor`.
|
||||
self.last_cursor_mode = Some(cursor_mode);
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
cursor = cursor_mode.name(),
|
||||
"sway headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
@@ -173,24 +191,75 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
// Same EXTEND problem as Hyprland: on a sway session with real heads this `HEADLESS-N`
|
||||
// sits beside them, and absolute input must be aimed at it by name. `swaymsg`'s output
|
||||
// name is the head's `wl_output.name`, which is what the injector matches.
|
||||
output_name: Some(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
|
||||
/// then unplug the output (fields drop in declaration order).
|
||||
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
|
||||
/// is actually closed**, and only then does [`OutputGuard`] unplug the output (fields drop in
|
||||
/// declaration order). This used to unplug first — see [`StopGuard`].
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
|
||||
/// then tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and unplugging the output anyway. See `hyprland.rs`'s twin.
|
||||
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Ceiling on the whole ScreenCast handshake, under the caller's 20 s wait — see the note at the
|
||||
/// handshake, and the longer one on `hyprland.rs`'s copy.
|
||||
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
|
||||
/// session**, so the caller may safely unplug the output afterwards.
|
||||
///
|
||||
/// 🛑 THE WAIT IS THE POINT. Root-caused on the Hyprland leg (see the long note on `hyprland.rs`'s
|
||||
/// `StopGuard`, which carries the measurements); the defect is the same here, and this is NOT an
|
||||
/// assumption of symmetry — xdpw was read to confirm it, against `emersion/xdg-desktop-portal-wlr`:
|
||||
///
|
||||
/// * **Only `Close` tears a session down.** `src/core/session.c` gives the session object exactly
|
||||
/// one method — `SD_BUS_METHOD("Close", …, method_close, …)` — and nothing else calls
|
||||
/// `xdpw_session_destroy` for a live cast. Like xdph, xdpw has no peer-vanished watcher of its own
|
||||
/// and depends entirely on xdg-desktop-portal's `peer_died_cb` calling `Close` for us, which
|
||||
/// happens only after our bus name goes away, asynchronously, and therefore after the old
|
||||
/// `StopGuard` had already let `OutputGuard` unplug the output.
|
||||
/// * **The same unbounded busy-wait is waiting for it.** `src/screencast/screencast.c:599-605`:
|
||||
/// `while (cast->node_id == SPA_ID_INVALID) { pw_loop_iterate(state->pw_loop, 0); }` — timeout 0,
|
||||
/// i.e. non-blocking, i.e. a hot spin on the portal's only loop with no escape if the stream never
|
||||
/// gets a node id. xdph's copy (`Screencopy.cpp:307-313`) is this code; that is the one measured
|
||||
/// pinning a core solid until it was restarted.
|
||||
///
|
||||
/// So sway's `output unplug` yanks a captured output out from under a live session exactly the way
|
||||
/// Hyprland's `output remove` did. Whether xdpw wedges *identically* has not been observed on glass
|
||||
/// — no sway box was available — but the two preconditions are present in its source, and closing
|
||||
/// the session before unplugging is the correct order regardless of what the backend does with it.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session. `None` when no cast
|
||||
/// was ever established — nothing to close, and nothing worth spending the budget on.
|
||||
closed: Option<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
return;
|
||||
};
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed, or the thread is gone without confirming — either way nothing holds the cast.
|
||||
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast session did not close in time — unplugging the output underneath \
|
||||
it; the next cast may find the portal busy"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +415,10 @@ impl Drop for ChooserFile {
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
fn select_and_cast(
|
||||
output: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
@@ -354,12 +426,21 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _chooser = ChooserFile(chooser);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
|
||||
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
|
||||
// `hw_cursor` is the request, not the answer.
|
||||
let (setup_tx, setup_rx) =
|
||||
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
|
||||
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
|
||||
// `StopGuard::drop` waits on it before the output is unplugged (see `StopGuard`). Kept a
|
||||
// SEPARATE channel from the setup one above — it fires at the other end of the cast's life,
|
||||
// long after `setup_rx` has been consumed.
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
@@ -368,9 +449,13 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
let mut guard = StopGuard { stop, closed: None };
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Ok((fd, node_id, cursor_mode))) => {
|
||||
// A cast exists now, so teardown has something to close and must wait for it.
|
||||
guard.closed = Some(closed_rx);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -387,10 +472,11 @@ pub(crate) fn stream_existing_output(
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
cursor_mode: Some(cursor_mode),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
@@ -513,7 +599,8 @@ fn ensure_xdpw_config() -> Result<()> {
|
||||
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
|
||||
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
|
||||
fn portal_thread(
|
||||
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
|
||||
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
|
||||
closed_tx: Sender<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
@@ -523,14 +610,13 @@ fn portal_thread(
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
// The SHARED, never-dropped runtime — see [`crate::portal_rt`] and the long note on
|
||||
// `hyprland.rs`'s copy: a per-cast runtime kills ashpd's process-global cached connection when
|
||||
// the cast ends, and every later handshake in the process then hangs.
|
||||
let rt = match crate::portal_rt::portal_runtime() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
let _ = setup_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -538,9 +624,20 @@ fn portal_thread(
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// Bounded, like `hyprland.rs`'s copy: an orphaned cached connection hangs HERE, before
|
||||
// any handshake call, so a bound that starts later never fires.
|
||||
let connect = async {
|
||||
Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)
|
||||
};
|
||||
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"connecting to the ScreenCast portal did not return within {}s",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
|
||||
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
|
||||
// screencast.c refuses the mode outright —
|
||||
@@ -549,51 +646,91 @@ fn portal_thread(
|
||||
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
|
||||
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
// Bounded for the same reason as `hyprland.rs`'s copy (the long note lives there): an
|
||||
// await on a wedged portal never returns, the `stop` flag is only read by the park loop
|
||||
// further down, so the thread leaks — and a leaked half-handshake poisons every later
|
||||
// portal request from this process. xdpw has the identical unbounded node-id spin as
|
||||
// xdph (`screencast.c`), so it can wedge the same way.
|
||||
let handshake = async {
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode.to_ashpd())
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context(
|
||||
"start response (chooser declined? check the xdpw config/chooser file)",
|
||||
)?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
Ok::<_, anyhow::Error>((session, fd, node_id))
|
||||
};
|
||||
let (session, fd, node_id) =
|
||||
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
|
||||
Ok(v) => v?,
|
||||
Err(_) => bail!(
|
||||
"the ScreenCast portal did not complete the handshake within {}s — \
|
||||
abandoning it instead of parking this thread on it forever (a hung \
|
||||
request poisons every later one from this process)",
|
||||
HANDSHAKE_BUDGET.as_secs()
|
||||
),
|
||||
};
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.send(Ok((fd, node_id, cursor_mode)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
|
||||
// the cast is torn down when the connection drops.
|
||||
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
|
||||
// 200 ms this used to use, because teardown now WAITS on what follows.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT IS UNPLUGGED. `Session.Close` is
|
||||
// the only thing that ends an xdpw session (`src/core/session.c`); dropping the
|
||||
// connection and trusting the peer to notice is not the contract. The caller is blocked
|
||||
// in `StopGuard::drop` on the signal below — see `StopGuard`. Bounded, so an
|
||||
// already-wedged portal cannot hang teardown with it.
|
||||
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => tracing::warn!(
|
||||
error = %e,
|
||||
"closing the ScreenCast session failed — the next cast may find the portal busy"
|
||||
),
|
||||
Err(_) => tracing::warn!(
|
||||
budget_s = CAST_CLOSE_BUDGET.as_secs(),
|
||||
"the ScreenCast portal did not answer Session.Close in time — it is probably \
|
||||
already wedged"
|
||||
),
|
||||
}
|
||||
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
|
||||
let _ = closed_tx.send(());
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -31,6 +31,12 @@ use anyhow::{bail, Context, Result};
|
||||
pub(crate) struct MirrorStream {
|
||||
pub node_id: u32,
|
||||
pub remote_fd: Option<std::os::fd::OwnedFd>,
|
||||
/// The cursor mode the xdg ScreenCast portal NEGOTIATED for this recording, for the two
|
||||
/// portal-based backends; `None` for the compositor-protocol ones (KWin/Mutter/gamescope),
|
||||
/// which get what they ask for. Reported on to the host as
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`] — same split as `remote_fd` above, and for the
|
||||
/// same reason: only the portal path has an answer that can differ from the request.
|
||||
pub cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
}
|
||||
@@ -40,6 +46,9 @@ pub struct MirrorDisplay {
|
||||
compositor: Compositor,
|
||||
connector: String,
|
||||
hw_cursor: bool,
|
||||
/// What the portal gave the most recent [`create`](VirtualDisplay::create), when this mirror
|
||||
/// delegated to a portal-based backend. See [`VirtualDisplay::last_portal_cursor_mode`].
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
}
|
||||
|
||||
impl MirrorDisplay {
|
||||
@@ -48,6 +57,7 @@ impl MirrorDisplay {
|
||||
compositor,
|
||||
connector,
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +75,10 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
self.hw_cursor
|
||||
}
|
||||
|
||||
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
|
||||
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
|
||||
@@ -125,10 +139,15 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
),
|
||||
};
|
||||
|
||||
// Latched for `last_portal_cursor_mode` — the delegate's verdict is this mirror's verdict.
|
||||
self.last_cursor_mode = stream.cursor_mode;
|
||||
|
||||
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
|
||||
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
|
||||
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
|
||||
// rather than set here per session.
|
||||
// rather than set here per session. We only CARRY the head's name out (`output_name`
|
||||
// below), which is what the wlr injector needs to bind its virtual pointer to this head —
|
||||
// the libei anchor above cannot serve it, because that backend selects by region.
|
||||
tracing::info!(
|
||||
connector = %target.connector,
|
||||
mode = %target.mode_label(),
|
||||
@@ -145,6 +164,9 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
out.remote_fd = stream.remote_fd;
|
||||
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
|
||||
out.ownership = DisplayOwnership::External;
|
||||
// The head absolute input maps into is the one we mirror — its connector IS its
|
||||
// `wl_output.name` on the wlroots/Hyprland backends, where the injector matches on it.
|
||||
out.output_name = Some(target.connector.clone());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,14 @@ mod pool {
|
||||
pub(super) keepalive: Box<dyn Send>,
|
||||
pub(super) node_id: u32,
|
||||
pub(super) preferred_mode: Option<(u32, u32, u32)>,
|
||||
/// The compositor's name for this output ([`VirtualOutput::output_name`]) — the identity the
|
||||
/// host aims absolute input with. Kept across a keep-alive reuse for the same reason
|
||||
/// `preferred_mode` is: the reused display IS the same head, so the output the caller is
|
||||
/// handed must answer with the same name a fresh create would. No poolable backend sets it
|
||||
/// today — the ones that do are all passed through unpooled (Hyprland/sway carry a portal
|
||||
/// fd, a mirror is `External`) — so this only exists so that stops being a silent trap the
|
||||
/// day one does.
|
||||
pub(super) output_name: Option<String>,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) backend: &'static str,
|
||||
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
|
||||
@@ -601,6 +609,7 @@ mod pool {
|
||||
keepalive: Box::new(()),
|
||||
node_id: 0,
|
||||
preferred_mode: None,
|
||||
output_name: None,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
@@ -1083,6 +1092,7 @@ mod linux {
|
||||
fn output_for(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
output_name: Option<String>,
|
||||
generation: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
reused: bool,
|
||||
@@ -1093,6 +1103,8 @@ mod linux {
|
||||
preferred_mode,
|
||||
Box::new(DisplayLease { generation, quit }),
|
||||
);
|
||||
// The head is the same one the entry was created for, so it answers with the same name.
|
||||
out.output_name = output_name;
|
||||
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
|
||||
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(generation);
|
||||
@@ -1176,6 +1188,7 @@ mod linux {
|
||||
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
|
||||
es[idx].generation = generation;
|
||||
let preferred_mode = es[idx].preferred_mode;
|
||||
let output_name = es[idx].output_name.clone();
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id,
|
||||
@@ -1184,6 +1197,7 @@ mod linux {
|
||||
ReuseOutcome::Reused(output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit.clone(),
|
||||
true,
|
||||
@@ -1279,6 +1293,7 @@ mod linux {
|
||||
|
||||
let node_id = real.node_id;
|
||||
let preferred_mode = real.preferred_mode;
|
||||
let output_name = real.output_name.clone();
|
||||
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
|
||||
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
|
||||
// display already renegotiated in its prior session (the producer's rebuilt offer persists
|
||||
@@ -1295,6 +1310,7 @@ mod linux {
|
||||
keepalive: real.keepalive,
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name: output_name.clone(),
|
||||
mode,
|
||||
backend,
|
||||
identity_slot,
|
||||
@@ -1349,7 +1365,14 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
|
||||
let mut out = output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
output_name,
|
||||
generation,
|
||||
quit,
|
||||
false,
|
||||
);
|
||||
out.expect_exact_dims = expect_exact_dims;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,17 @@ pub fn capture_virtual_output(
|
||||
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
|
||||
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
|
||||
// the floor, which is what kept the Linux native plane at 8 bits.
|
||||
//
|
||||
// Aim the wlr injector's absolute mapping (abs-mouse, and `park_pointer`'s opening warp) at
|
||||
// THIS head — the Linux counterpart of the `set_stream_target` call in the Windows arm below.
|
||||
// The wlroots virtual pointer maps `motion_absolute` onto the `wl_output` it was created with,
|
||||
// and on the EXTEND backends (Hyprland, sway) the streamed head sits BESIDE the operator's, so
|
||||
// without this every absolute sample landed on their screen and the cursor never entered the
|
||||
// stream at all. `None` (KWin/Mutter/gamescope, none of which inject through that backend)
|
||||
// CLEARS the slot rather than leaving a stale name: one compositor serves the whole host, so a
|
||||
// `None` here means no session on this host wants a named binding — e.g. a Game-Mode switch
|
||||
// from a Hyprland desktop to gamescope, after which the old `PF-…` name means nothing.
|
||||
crate::inject::set_stream_output(vout.output_name.clone());
|
||||
pf_capture::open_virtual_output(
|
||||
vout.remote_fd,
|
||||
vout.node_id,
|
||||
|
||||
@@ -1347,8 +1347,19 @@ pub(super) struct SessionContext {
|
||||
/// its embedded mode paints nothing either) the stream has NO cursor at all, in both the
|
||||
/// embedded and the cursor-channel composite models. Parking once per (re)built display — and
|
||||
/// again on the mid-stream flip to the capture model, which heals a pointer that drifted off the
|
||||
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
|
||||
/// client overrides it with its first absolute move, so the jump is invisible in practice.
|
||||
/// output's edge — pins the pointer to the surface the client actually sees.
|
||||
///
|
||||
/// **Retried only for a relative-only client.** The schedule used to repeat this for every session,
|
||||
/// on the theory that "a desktop-model client overrides it with its first absolute move, so the
|
||||
/// jump is invisible in practice". One park at bring-up is; a *repeat* is not. A desktop-model
|
||||
/// client sends absolute positions — the very same [`MouseMoveAbs`] this synthesizes, only aimed
|
||||
/// where the user is actually pointing — so once those are flowing the park has nothing left to add
|
||||
/// and every later attempt is a visible yank to centre that fights them (field report 2026-08-14).
|
||||
/// The schedule below therefore keeps the single bring-up park for such a client (so its first
|
||||
/// click cannot land on the monitor the seat pointer was left on) and drops the retry; the
|
||||
/// capture-model flip re-arms the full schedule.
|
||||
///
|
||||
/// [`MouseMoveAbs`]: punktfunk_core::input::InputKind::MouseMoveAbs
|
||||
#[cfg(target_os = "linux")]
|
||||
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
|
||||
let ev = punktfunk_core::input::InputEvent {
|
||||
@@ -1375,6 +1386,65 @@ fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle this session's cursor plan against the cursor mode the display's portal ACTUALLY
|
||||
/// negotiated — THE rule, shared by bring-up and the capture-loss rebuild so the two cannot drift.
|
||||
///
|
||||
/// Returns whether "the capture has no cursor overlay" still MEANS anything on this display, and
|
||||
/// clears `metadata_composite` when the negotiated mode makes it a fiction. Both answers come from
|
||||
/// the same fact, which is why they are settled together.
|
||||
///
|
||||
/// The fact: [`set_hw_cursor`] is a *request*. On the whole wlr family (xdph, xdpw) the portal
|
||||
/// advertises `Hidden|Embedded` — measured, `AvailableCursorModes = 3`, on current packages — so a
|
||||
/// session that asked for metadata is served **Embedded**: the compositor paints the pointer into
|
||||
/// the frames and sends no `SPA_META_Cursor`, ever, wherever the pointer is. That breaks two
|
||||
/// downstream beliefs at once:
|
||||
///
|
||||
/// * the host planned a metadata composite it can never feed (the stream logs "host-composite
|
||||
/// active but the capture has no live cursor overlay yet" forever, and shows no host pointer —
|
||||
/// the compositor's own burnt-in one is the real cursor there), and
|
||||
/// * the seat-pointer park schedule reads "no overlay" as "the pointer has not reached the streamed
|
||||
/// output". That inference is sound on **Mutter**, which suppresses cursor metadata while the
|
||||
/// pointer is off the recorded view (`should_cursor_metadata_be_set`) — it is the signal
|
||||
/// [`park_pointer`] was built on — and it is pure noise under Embedded, where the host then
|
||||
/// re-centres the user's pointer once a second for the whole cap, fighting every mouse movement
|
||||
/// (field report 2026-08-14, Hyprland).
|
||||
///
|
||||
/// A backend that reports no negotiated mode (`None` — KWin, Mutter, gamescope, Windows) is served
|
||||
/// the cursor mode it asked for through its own protocol, so nothing here applies and both answers
|
||||
/// stay exactly as they were.
|
||||
///
|
||||
/// What this does NOT undo is [`SessionPlan::cursor_blend`]: the blend capability is resolved
|
||||
/// before any display exists, and the negotiation only happens inside `create`, so a session that
|
||||
/// lands on Embedded still captures RGB for a blend stage it will never be handed an overlay for.
|
||||
/// That costs a colour conversion, not correctness — and pre-judging it would mean re-asserting
|
||||
/// what the wlr portals advertise, which is exactly the hardcode `pf_vdisplay::portal_cursor`
|
||||
/// exists to have deleted.
|
||||
///
|
||||
/// [`set_hw_cursor`]: pf_vdisplay::VirtualDisplay::set_hw_cursor
|
||||
/// [`SessionPlan::cursor_blend`]: crate::session_plan::SessionPlan::cursor_blend
|
||||
#[cfg(target_os = "linux")]
|
||||
fn settle_portal_cursor(
|
||||
vd: &dyn crate::vdisplay::VirtualDisplay,
|
||||
metadata_composite: &mut bool,
|
||||
) -> bool {
|
||||
let Some(negotiated) = vd.last_portal_cursor_mode() else {
|
||||
return true;
|
||||
};
|
||||
if negotiated.delivers_metadata() {
|
||||
return true;
|
||||
}
|
||||
if *metadata_composite {
|
||||
*metadata_composite = false;
|
||||
tracing::info!(
|
||||
negotiated = negotiated.name(),
|
||||
"the portal negotiated a cursor mode that carries no cursor metadata — dropping the \
|
||||
host composite; the pointer in this stream is the compositor's own, burnt into the \
|
||||
frames"
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
|
||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||
@@ -1705,6 +1775,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
mut cur_display_gen,
|
||||
built_bitrate,
|
||||
) = pipe;
|
||||
// The display exists now, so the portal has answered: settle the cursor plan against what it
|
||||
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
|
||||
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut no_overlay_means_off_output = settle_portal_cursor(&*vd, &mut metadata_composite);
|
||||
// The encoder may have opened at a re-resolved rate (a mirrored head delivering a size this
|
||||
// session never negotiated). Adopt it before anything downstream reads `bitrate_kbps`.
|
||||
adopt_built_bitrate(
|
||||
@@ -2064,12 +2139,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`.
|
||||
let (mut diag_new, mut diag_repeat) = (0u64, 0u64);
|
||||
// Seat-pointer park schedule (see `park_pointer`): per (re)built display, and re-armed by
|
||||
// the capture-model flip. More than one attempt because the first park of a session can
|
||||
// land on a still-cold EIS connection (devices not yet resumed → the injector DROPS it) —
|
||||
// observed on-glass; the retry a second later goes through. While the session is in the
|
||||
// the capture-model flip. More than one attempt for a RELATIVE-ONLY session, because the
|
||||
// first park can land on a still-cold EIS connection (devices not yet resumed → the injector
|
||||
// DROPS it) — observed on-glass; the retry a second later goes through. A client that steers
|
||||
// the pointer itself gets the bring-up park only: its own absolute moves are the retry, and
|
||||
// a synthetic one on top of them is just a yank to centre. While the session is in the
|
||||
// capture model with no live cursor overlay, keep trying up to the cap: no overlay there
|
||||
// means the pointer still isn't on the streamed output, and a relative-only client can
|
||||
// never fix that itself.
|
||||
// never fix that itself — but only where an absent overlay is evidence of anything at all
|
||||
// (`no_overlay_means_off_output`, settled per display by `settle_portal_cursor`).
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut parked_display = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -3033,6 +3111,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_display_gen;
|
||||
// The rebuild re-ran `create`, so the portal answered again — possibly a different
|
||||
// backend's portal (the retarget above), possibly with a different verdict. Settle
|
||||
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
|
||||
// recomputes `metadata_composite` from the compositor alone (it has to — it runs
|
||||
// BEFORE the rebuild, to set `hw_cursor`), so without this a switch onto a wlr
|
||||
// backend would re-arm both defects mid-session.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
no_overlay_means_off_output =
|
||||
settle_portal_cursor(&*vd, &mut metadata_composite);
|
||||
}
|
||||
// A capture-loss rebuild can land on a different source than it lost (this loop
|
||||
// re-detects the session every cycle, precisely so it can follow a switch), so the
|
||||
// delivered size — and with it an Automatic rate — may have changed under us.
|
||||
@@ -3136,10 +3225,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
None => {
|
||||
if !composite_saw_none {
|
||||
composite_saw_none = true;
|
||||
// NOT necessarily "cursorless", which this line used to assert:
|
||||
// where the portal negotiated Embedded (the whole wlr family) no
|
||||
// `SPA_META_Cursor` is ever sent and the compositor's own pointer
|
||||
// is already in the pixels — the host blend has nothing to add.
|
||||
// `settle_portal_cursor` logs which of the two this session is.
|
||||
tracing::info!(
|
||||
"host-composite active but the capture has no live cursor \
|
||||
overlay yet (no SPA_META_Cursor bitmap) — the stream is \
|
||||
cursorless until one arrives"
|
||||
overlay (no SPA_META_Cursor bitmap) — nothing for the encoder \
|
||||
blend to draw; the pointer, if any, is the compositor's own"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3189,14 +3283,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
frame.cursor = None;
|
||||
}
|
||||
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
|
||||
// the first frame of every (re)built display and by the capture-model flip). The first
|
||||
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
|
||||
// connection. Past those, only a host-composite session that STILL has no live overlay
|
||||
// keeps trying — a channel session in the capture model, or a no-channel
|
||||
// metadata-composite session (both relative-only): no overlay there means the pointer
|
||||
// has not reached the streamed output (the compositor reports cursor metadata only
|
||||
// while it is over the recorded view), and a relative-only client cannot get it there
|
||||
// on its own.
|
||||
// the first frame of every (re)built display and by the capture-model flip).
|
||||
//
|
||||
// Two unconditional attempts for a RELATIVE-ONLY session — attempt 1 can be swallowed by
|
||||
// a cold EIS connection, and nothing else will ever move that pointer onto the output.
|
||||
// Exactly ONE for a client that steers the seat pointer itself (a channel client in the
|
||||
// desktop model): the park still runs once at bring-up, so the session's first click
|
||||
// cannot land on whatever monitor the seat pointer was left on, but the retry a second
|
||||
// later is dropped — by then the client's own absolute moves are doing this job, better
|
||||
// (same event, aimed where the user is actually pointing), and the retry is a visible
|
||||
// yank to centre that fights them (field report 2026-08-14; see `park_pointer`). A cold
|
||||
// EIS swallows the client's moves too, and those keep coming, so it needs no retry.
|
||||
//
|
||||
// Past the unconditional attempts, only a host-composite session that STILL has no live
|
||||
// overlay keeps trying — a channel session in the capture model, or a no-channel
|
||||
// metadata-composite session: no overlay there means the pointer has not reached the
|
||||
// streamed output (the compositor reports cursor metadata only while it is over the
|
||||
// recorded view), and a relative-only client cannot get it there on its own.
|
||||
//
|
||||
// ...but ONLY where that inference holds — `no_overlay_means_off_output`, settled per
|
||||
// display by `settle_portal_cursor`. Under a portal that negotiated Embedded no cursor
|
||||
// metadata is EVER sent, so "no overlay" says nothing about where the pointer is, and
|
||||
// this heuristic re-centred the user's pointer for the full cap on every Hyprland/sway
|
||||
// session (the same field report). Those sessions still get the two unconditional
|
||||
// attempts, which are what puts the pointer — and thus the compositor's burnt-in cursor,
|
||||
// and the client's input — on the streamed output in the first place.
|
||||
//
|
||||
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
|
||||
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
|
||||
// parked regardless.
|
||||
@@ -3205,17 +3317,22 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
&& park_attempts < PARK_ATTEMPTS_MAX
|
||||
&& std::time::Instant::now() >= next_park_at
|
||||
{
|
||||
let composite_starved = ((cursor_fwd.is_some()
|
||||
&& !cursor_client_draws.load(Ordering::Relaxed))
|
||||
let client_steers = cursor_fwd.is_some() && cursor_client_draws.load(Ordering::Relaxed);
|
||||
let unconditional = if client_steers { 1 } else { 2 };
|
||||
// Never true while `client_steers`: the channel term excludes it outright, and
|
||||
// `metadata_composite` implies no channel at all.
|
||||
let composite_starved = ((cursor_fwd.is_some() && !client_steers)
|
||||
|| metadata_composite)
|
||||
&& capturer.cursor().is_none();
|
||||
if park_attempts < 2 || composite_starved {
|
||||
&& capturer.cursor().is_none()
|
||||
&& no_overlay_means_off_output;
|
||||
if park_attempts < unconditional || composite_starved {
|
||||
park_pointer(&input_tx, frame.width, frame.height);
|
||||
park_attempts += 1;
|
||||
next_park_at = std::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
} else {
|
||||
// Settled (overlay flowing, or the client draws): stop scheduling until a
|
||||
// rebuild or a capture-model flip re-arms it.
|
||||
// Settled (the client steers, the overlay is flowing, or its absence carries no
|
||||
// information here): stop scheduling until a rebuild or a capture-model flip
|
||||
// re-arms it.
|
||||
park_attempts = PARK_ATTEMPTS_MAX;
|
||||
}
|
||||
}
|
||||
@@ -5115,4 +5232,65 @@ mod tests {
|
||||
"a +2 ms offset must shift the next target by +2 ms mod P, got {shift}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The 2026-08-14 Hyprland field report, in one function: xdph advertises `Hidden|Embedded`,
|
||||
/// so a session that asked for cursor metadata is served **Embedded** — no `SPA_META_Cursor`
|
||||
/// is ever sent, whatever the pointer does. The host must then (a) stop planning a metadata
|
||||
/// composite it can never feed, and (b) stop reading "no cursor overlay" as "the seat pointer
|
||||
/// is not on the streamed output" — the inference that re-centred the user's pointer once a
|
||||
/// second for the whole park cap.
|
||||
///
|
||||
/// The `None` case is the regression guard for GNOME: Mutter is served the cursor mode it
|
||||
/// asks for through its own protocol and DOES suppress metadata while the pointer is off the
|
||||
/// recorded view, which is the signal `park_pointer` exists for. Nothing here may touch it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_embedded_portal_voids_both_the_composite_and_the_starvation_signal() {
|
||||
struct Fake(Option<pf_vdisplay::PortalCursorMode>);
|
||||
impl crate::vdisplay::VirtualDisplay for Fake {
|
||||
fn name(&self) -> &'static str {
|
||||
"fake"
|
||||
}
|
||||
fn create(
|
||||
&mut self,
|
||||
_mode: pf_vdisplay::Mode,
|
||||
) -> anyhow::Result<crate::vdisplay::VirtualOutput> {
|
||||
anyhow::bail!("this test never creates a display")
|
||||
}
|
||||
fn last_portal_cursor_mode(&self) -> Option<pf_vdisplay::PortalCursorMode> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// The whole wlr family, today: metadata wanted, Embedded served.
|
||||
let mut composite = true;
|
||||
assert!(!settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Embedded)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(!composite, "the composite can never be fed — drop it");
|
||||
|
||||
// Hidden is the same story from the other end: no pointer, so no overlay, so no signal.
|
||||
let mut composite = true;
|
||||
assert!(!settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Hidden)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(!composite);
|
||||
|
||||
// A portal that really does serve metadata (xdph ≥ #366, or the portal path on
|
||||
// KWin/Mutter): everything stays exactly as it was.
|
||||
let mut composite = true;
|
||||
assert!(settle_portal_cursor(
|
||||
&Fake(Some(pf_vdisplay::PortalCursorMode::Metadata)),
|
||||
&mut composite
|
||||
));
|
||||
assert!(composite);
|
||||
|
||||
// Not portal-negotiated at all — KWin `zkde_screencast`, Mutter `RecordVirtual`,
|
||||
// gamescope, Windows. THE no-regression case.
|
||||
let mut composite = true;
|
||||
assert!(settle_portal_cursor(&Fake(None), &mut composite));
|
||||
assert!(composite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,29 +103,24 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
|
||||
|
||||
## Windows
|
||||
|
||||
The Windows client ships as a **signed MSIX** in the package registry. Builds use a self-signed
|
||||
certificate, so you import that certificate once before Windows will install the package.
|
||||
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
|
||||
trusted certificate — so there is nothing to import and nothing to trust by hand. Download, install.
|
||||
|
||||
1. Download the package and its certificate. Each channel keeps one fixed URL, so these two lines
|
||||
always fetch the current build — in PowerShell:
|
||||
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
|
||||
build — in PowerShell:
|
||||
|
||||
```powershell
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
|
||||
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.cer
|
||||
```
|
||||
|
||||
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
|
||||
two files are attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
|
||||
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
|
||||
build is also kept under its own version on the
|
||||
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
|
||||
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
|
||||
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
|
||||
later updates need nothing. In an **admin** PowerShell:
|
||||
2. Install it:
|
||||
|
||||
```powershell
|
||||
# use the _arm64 files instead on an Arm device
|
||||
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
|
||||
-CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
# use the _arm64 file instead on an Arm device
|
||||
Add-AppxPackage .\punktfunk-client-windows_x64.msix
|
||||
```
|
||||
|
||||
@@ -133,6 +128,10 @@ certificate, so you import that certificate once before Windows will install the
|
||||
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
|
||||
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
|
||||
|
||||
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
|
||||
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime it depends on is
|
||||
in use and Windows can't restart the apps holding it.
|
||||
|
||||
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
|
||||
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
|
||||
TV or HTPC — and the headless `punktfunk` command on your PATH.
|
||||
@@ -223,7 +222,7 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
|
||||
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk` — **without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
|
||||
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
|
||||
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
|
||||
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. The certificate is the same every release, so you don't import it again |
|
||||
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
|
||||
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
|
||||
| **iOS / iPadOS / tvOS** | TestFlight updates it |
|
||||
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
|
||||
@@ -244,6 +243,20 @@ systemctl reboot
|
||||
The client's own updater below runs exactly that dance for you, if you'd rather not remember it. (A
|
||||
layered **host** has the same trap — [Updating](/docs/updating) covers it.)
|
||||
|
||||
**Windows, coming from 0.28.1 or earlier — uninstall first.** Those builds were signed with our own
|
||||
self-signed certificate. The move to a publicly trusted one changes the package's *publisher*, and an
|
||||
MSIX's identity is its name **plus** its publisher — so Windows treats the new package as a different
|
||||
app rather than an update, and installing it leaves you with two **Punktfunk** entries. Remove the
|
||||
old one first, then install the new `.msix` as above:
|
||||
|
||||
```powershell
|
||||
Get-AppxPackage *Punktfunk* | Remove-AppxPackage
|
||||
```
|
||||
|
||||
This is one-time; releases after that upgrade in place. Note that a packaged app's settings live
|
||||
*inside* its package, so removing the old one also removes this client's identity and its paired
|
||||
hosts — expect to [pair](/docs/pairing) again once. Nothing on the host side is affected.
|
||||
|
||||
### The Linux client can update itself
|
||||
|
||||
The native Linux client checks its own channel and can apply the update in place, so you don't have
|
||||
|
||||
@@ -70,22 +70,22 @@ everything the installer puts on the machine, its optional tasks, the console pa
|
||||
This is also the path for **canary** builds, which winget doesn't carry — see
|
||||
[Release Channels](/docs/channels) for that download.
|
||||
|
||||
> **About the Unknown Publisher prompt.** The installer is signed with a self-signed certificate, so
|
||||
> Windows warns before it runs — accepting the prompt is enough, nothing else is required. The winget
|
||||
> route is no different: it downloads and runs that same installer. If you'd
|
||||
> rather silence it, the matching **`punktfunk-host-windows_<ver>.cer`** is published next to the
|
||||
> installer, and it's the **same certificate for every release**, so this is one-time. A self-signed
|
||||
> certificate is its own root, so it has to go in both stores. In an **admin** PowerShell:
|
||||
> **About signing.** The installer is signed with a publicly trusted certificate, so Windows shows
|
||||
> the publisher by name at the UAC prompt — there is no Unknown Publisher warning and nothing to
|
||||
> import. The winget route is no different: it downloads and runs that same installer.
|
||||
>
|
||||
> ```powershell
|
||||
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||
> -CertStoreLocation Cert:\LocalMachine\Root
|
||||
> Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||
> -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
|
||||
> ```
|
||||
> SmartScreen is a separate mechanism that builds reputation per publisher, so shortly after a new
|
||||
> signing certificate starts being used it can still show *"Windows protected your PC"* on the first
|
||||
> downloads — **More info → Run anyway**. It settles as installs accumulate.
|
||||
>
|
||||
> This is a different certificate from the one the bundled **drivers** are signed with — the
|
||||
> installer imports that one for you.
|
||||
> The bundled **drivers** are a separate matter — they carry their own certificate, and the installer
|
||||
> imports that one for you. [Windows Host](/docs/windows-host#about-the-signatures) has the detail.
|
||||
>
|
||||
> Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and older docs
|
||||
> told you to import it. Nothing needs it any more: if you imported
|
||||
> `punktfunk-host-windows_<ver>.cer` back then, you can remove it from `Cert:\LocalMachine\Root` and
|
||||
> `Cert:\LocalMachine\TrustedPublisher` (look for the certificate issued to **unom**, thumbprint
|
||||
> `CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`).
|
||||
|
||||
## NixOS
|
||||
|
||||
|
||||
@@ -227,9 +227,12 @@ Three things are left on purpose:
|
||||
component other apps may be using, so the Punktfunk uninstaller never touches it. Remove it
|
||||
with its own uninstaller — `VBCABLE_Setup_x64.exe -u -h` — or the **VB-Audio Virtual Cable**
|
||||
entry in Installed apps.
|
||||
- **The publisher certificate**, if you imported it by hand to silence the Unknown Publisher prompt.
|
||||
Remove it in `certlm.msc` under **Trusted Publishers** and **Trusted Root Certification
|
||||
Authorities**. (This is *not* the driver certificate above, which the uninstaller does remove.)
|
||||
- **The old publisher certificate**, if you imported it by hand to silence the Unknown Publisher
|
||||
prompt on 0.28.1 or earlier. Releases since then are signed by a publicly trusted CA and never
|
||||
needed it, so it is safe to drop: remove the certificate issued to **unom** (thumbprint
|
||||
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`) in `certlm.msc` under **Trusted Publishers** and
|
||||
**Trusted Root Certification Authorities**. (This is *not* the driver certificate above, which the
|
||||
uninstaller does remove.)
|
||||
|
||||
If you registered the winget source, drop it too — in an **admin** PowerShell, the same as
|
||||
registering it:
|
||||
|
||||
@@ -94,12 +94,14 @@ are `winget upgrade unom.PunktfunkHost` and removal is `winget uninstall unom.Pu
|
||||
|
||||
### About the signatures
|
||||
|
||||
Punktfunk signs with its own certificates rather than a publicly trusted one, so Windows warns about
|
||||
an unknown publisher before setup runs. Accepting the prompt is enough. To silence it for good, the
|
||||
matching **`punktfunk-host-windows_<ver>.cer`** is published next to the installer and it is the
|
||||
same certificate for every release — the one-time import is in
|
||||
[Install → Windows](/docs/install#windows). This applies to the winget path too: winget downloads
|
||||
and runs that same installer.
|
||||
Setup, and the Punktfunk executables it installs, are signed with a **publicly trusted** certificate,
|
||||
so UAC names the publisher rather than warning about an unknown one, and there is nothing for you to
|
||||
import. This applies to the winget path too: winget downloads and runs that same installer. (The
|
||||
bundled third-party pieces keep whatever their own vendors shipped.)
|
||||
|
||||
Releases **0.28.1 and earlier** were signed with our own self-signed certificate, and the docs then
|
||||
asked you to import `punktfunk-host-windows_<ver>.cer`. Current builds no longer produce or need it —
|
||||
[Install → Windows](/docs/install#windows) shows how to remove it if you imported one.
|
||||
|
||||
The bundled drivers carry a **second, separate** self-signed certificate (`CN=punktfunk-driver`,
|
||||
SHA-1 thumbprint `4B8493E7CD565758D335F8F4F05C5A7261A13E02`). The installer adds it to the machine's
|
||||
@@ -114,12 +116,22 @@ punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
|
||||
Keep `/SUPPRESSMSGBOXES`: without it an unattended run can stop on a message box nobody can see.
|
||||
|
||||
A silent run takes the same defaults as the wizard, so add or drop individual options with Inno's
|
||||
`/MERGETASKS` — a bare name adds a task, a `!` prefix removes one. `/MERGETASKS="gamestream"` turns
|
||||
on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the status icon. The task names are
|
||||
`installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`, `gamestream`,
|
||||
A silent **first** install takes the same defaults as the wizard, so add or drop individual options
|
||||
with Inno's `/MERGETASKS` — a bare name adds a task, a `!` prefix removes one.
|
||||
`/MERGETASKS="gamestream"` turns on Moonlight compatibility; `/MERGETASKS="!trayicon"` skips the
|
||||
status icon. The task names are `installdriver`, `installgamepad`, `installhdrlayer`, `gamestream`,
|
||||
`allowpublicfw`, `startservice` and `trayicon`.
|
||||
|
||||
An **upgrade** is different: it reuses the choices the previous install recorded, not the defaults
|
||||
above. That is usually what you want, but it means anything you once declined stays declined — and
|
||||
if that includes `installgamepad`, the gamepad drivers are never updated alongside the host, which
|
||||
surfaces later as a virtual controller games stop seeing. To force the full set on an upgrade, name
|
||||
them (`/MERGETASKS` merges with the remembered set rather than replacing it):
|
||||
|
||||
```powershell
|
||||
punktfunk-host-setup-<ver>.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS="installdriver,installgamepad,installhdrlayer,startservice"
|
||||
```
|
||||
|
||||
Two things to know before you script it:
|
||||
|
||||
- Setup **aborts with a non-zero exit code** if another Moonlight-compatible host — Sunshine, Apollo,
|
||||
|
||||
@@ -24,6 +24,16 @@ release is born complete and the announcement always has something to say.
|
||||
Discord `#releases`. Pressing "go" is the quality gate — a half-built release is never
|
||||
announced. Stable-only; a `-rc` tag is refused unless `allow_prerelease=true`.
|
||||
|
||||
**If a platform's run never appears, do not re-run the PR run — it cannot publish.** A re-run
|
||||
replays the original event (`pull_request`), and android's publish steps are gated on a `push`, so
|
||||
they stay skipped no matter how often you press it. Merging two PRs seconds apart can leave the
|
||||
older merge sha with **no run at all** — Gitea attributes the window's runs to the newer head
|
||||
(2026-08-14: `1e5dca4c` lost its run to `b5cace3a`, 12 s later), which is how an android change
|
||||
reaches main having never been built. Recover it by dispatching `android.yml` on that ref with
|
||||
**`publish=true`**; that is the only manual path reaching the registry and Play, and a plain
|
||||
dispatch stays build-only so a stray click can't ship to testers. Check for the gap by matching
|
||||
your own merge sha in the run list — "CI ran" is not the same as "your commit ran".
|
||||
|
||||
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body
|
||||
via the API) — the announce step always re-syncs from the file, so the file stays authoritative
|
||||
even across a tag re-point.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
Wire-compatible with 0.28.x — everything you have already paired keeps working, and you can update one side at a time. An old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
This release is about getting along: with the other streaming host on your machine, with the game that is hogging your processor, and with Windows itself. Punktfunk can now share a computer with Sunshine and its forks, Linux hosts keep the stream smooth while a game hammers the CPU, streaming from a Hyprland desktop finally works — and keeps working past the first session — and Windows downloads are now signed under a publicly verified publisher.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Punktfunk and Sunshine (or Apollo, or another fork) can now live on the same machine.** Move one port in Punktfunk's settings and you are done — every client finds the new port by itself.
|
||||
- **Linux hosts stop stuttering when a game hogs the processor.** The streaming threads were supposed to run at high priority and never actually did, on any install to date.
|
||||
- **If your Windows client is the app package (the `.msix` download), this one update needs an uninstall first** — see *Before you update*. The regular installer updates normally.
|
||||
- **Streaming from a Hyprland desktop actually works now — and keeps working.** Before, the picture could refuse to appear at all, and at best the first session of a host's lifetime was also its last good one.
|
||||
- **A controller whose Select button reports itself as "Back" no longer quits your Android stream on one press.**
|
||||
- **Mac clients can finally capture system shortcuts:** ⌘Q reaches the host's desktop instead of closing the client.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **Windows client installed as the app package (the `.msix` download):** Windows will refuse to update it in place this one time, because the package's signing identity changed with the move to a verified certificate. Uninstall the old package, then install the new one. This does not apply to the regular installer, and future updates go back to normal.
|
||||
|
||||
## New
|
||||
|
||||
- **Share a machine with Sunshine, Apollo and friends.** With Moonlight-compatible connections switched off, the two only ever fought over a single network port — and moving Punktfunk's was barely possible: the setting did not survive updates, and clients had no way to find the moved port unless local network discovery happened to be working. All of that is fixed. The port is now a real setting in the host's configuration file, it survives upgrades, the web console follows it, and every client learns it directly from the host the moment it connects — so it also works over a VPN or across subnets, where discovery has never worked. The connection port can be moved the same way.
|
||||
- **Capture system shortcuts, now on Mac too.** The setting existed on every other client; on a Mac, ⌘Q quit the client itself and other ⌘ shortcuts went to the client's menus instead of your game. While your input is captured, ⌘ shortcuts are now sent to the host — with the escape shortcuts kept local, so there is always a way out. On by default, like everywhere else.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Linux hosts keep the stream smooth under load.** The threads that capture, encode and send your stream were meant to run ahead of ordinary programs and never did — the request was silently refused on every install channel. A game's shader-compilation storm could starve them, stutter the audio and drag the picture quality to the floor on a connection with nothing wrong. The host now asks the system's scheduling service the way audio apps do, the packages ship the needed headroom for machines without it, and — for the first time — the audio path itself is prioritized, which matters most because a late sound packet is directly audible.
|
||||
- **The host's volume boost is now trustworthy.** It quietly did nothing on normal Punktfunk connections, and where it did work, turning it up clipped the sound into harsh distortion long before it got loud. It now applies everywhere, and levels above the safe range are rounded off gently instead of clipped — boosting a quiet desktop mix finally sounds like turning it up, not breaking it. It buys headroom for quiet sources; it is deliberately not a loudness compressor.
|
||||
- **Windows downloads are signed under a publicly verified certificate.** The installer and the app package now carry a verified publisher signature instead of a self-signed one — what Windows shows when it asks "do you trust this?" is now an identity a certificate authority actually checked.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Streaming your desktop from Hyprland works now, and keeps working.** Six separate faults, every one of them ours, stacked up on this route: the capture could fail to start at all because Punktfunk offered the picture in a format the desktop's screen-sharing service never accepts; when it did start, only the first session of the host's lifetime worked and every later one was black; ending a stream could wedge the screen-sharing service itself; a stalled start could quietly poison every session after it; your mouse pointer was aimed at the host's own monitor instead of the streamed one; and the host misread the service's normal behaviour as a lost pointer. All six are fixed — measured against a fully up-to-date Hyprland, where it now streams session after session.
|
||||
- **Your Playnite library now shows up on Windows.** Playnite installs per-user by default, and the host — which runs as a system service — was looking in places where a per-user install can never be found, so syncing failed. Worse, the one tile it could not open took every game down with it. Both are fixed: a normal Playnite install is found, and a shortcut that cannot open on your machine is skipped with a note instead of emptying the library.
|
||||
- **One press of Select no longer ends your Android stream.** Some controllers — especially the Android TV shape — report their Select button as the same "Back" every TV remote has. That press fell through to "leave the stream", and every shortcut built on Select (the emergency exit hold, mic mute, the stats overlay) was unreachable on exactly those pads. Select now works as Select on them; a remote's Back still exits, as the on-screen hint says.
|
||||
- **Streaming a game in its own full-screen session from a KDE desktop no longer leaves your monitor lit.** With the host set to take the machine over exclusively, this one way of launching a game kept the physical monitor showing the idle desktop for the whole stream. It now goes dark, wakes when you touch the local mouse or keyboard, and never wakes from anything happening inside the stream.
|
||||
- **Mac keyboard edge cases in a stream:** with Caps Lock on, the escape shortcuts were not recognized at all, and a shortcut's key could stay "stuck" until pressed again. Both fixed alongside the shortcut-capture work.
|
||||
|
||||
## Thanks
|
||||
|
||||
The Linux priority fix exists because someone measured their stutter carefully enough — down to which seconds it happened in and what else the machine was doing — that the cause could be found in a place nobody was looking. The Select-button fix likewise arrived as a precise field report. Thank you; that detail is what makes a fault findable.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table and the breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.29.0/CHANGELOG.md).
|
||||
|
||||
Two things deserve a headline even here: the C ABI moves 19 → 20 by pure addition (one new accessor for the host's advertised management port), and the Windows MSIX package identity changed with the signing move — that is the uninstall note above, and it is one-time.
|
||||
@@ -0,0 +1,2 @@
|
||||
• One press of Select no longer ends your stream. Some controllers report Select as the "Back" every TV remote has; that press quit the session, and the Select shortcuts (the emergency exit hold, mic mute, stats) never worked on those pads. Fixed — a remote's Back still exits.
|
||||
• The app now learns a host's settings port from the host itself when it connects, so hosts reached over a VPN or another network work fully, without local discovery.
|
||||
@@ -303,7 +303,8 @@ pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetD
|
||||
|
||||
Push a `vX.Y.Z` tag — one tag releases every platform (see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
|
||||
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
|
||||
publishes `punktfunk-host-setup-X.Y.Z.exe` (no `.cer` — Azure signing is publicly trusted, and mode 2
|
||||
or 3 would be needed to emit one), refreshes the stable `latest/`
|
||||
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
|
||||
`<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
|
||||
`scripts/ci/pf-version.ps1`) to the `canary/` alias.
|
||||
|
||||
Reference in New Issue
Block a user