Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e753fd84d | ||
|
|
a17571c6bf | ||
|
|
97f81a6ea6 | ||
|
|
b8ec8ea260 | ||
|
|
4aeec6051d | ||
|
|
da13d14159 | ||
|
|
bb781ca694 | ||
|
|
662df795b2 | ||
|
|
9a05750376 | ||
|
|
6cd25b4829 | ||
|
|
4369e3b2ec | ||
|
|
ff33eb872e | ||
|
|
6ada74066d | ||
|
|
59d8b8a677 | ||
|
|
6b3e793b39 | ||
|
|
ab55dd4e39 | ||
|
|
ab8fa46b66 | ||
|
|
c32fad8aee | ||
|
|
6753641c5e | ||
|
|
19411d8d6d | ||
|
|
b2e6debb22 | ||
|
|
1bed82423d | ||
|
|
ee6dff116c | ||
|
|
25487a8bd4 | ||
|
|
97fe3a0ff5 | ||
|
|
00f9c1f4d3 | ||
|
|
18f595698c | ||
|
|
8ae524d801 | ||
|
|
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: |
|
||||
|
||||
+16
-4
@@ -114,8 +114,14 @@ jobs:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
|
||||
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
|
||||
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
|
||||
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
|
||||
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
@@ -258,8 +264,14 @@ jobs:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
|
||||
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
|
||||
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
|
||||
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
|
||||
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
|
||||
# control is operator-side: Gitea's "require approval for fork PRs".)
|
||||
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-ci-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
|
||||
@@ -363,6 +363,12 @@ jobs:
|
||||
envs: REGISTRY_TOKEN
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# Log out on EVERY exit path: unlike the ephemeral LAN-registry runners, this is a
|
||||
# long-lived internet-facing VM, so a `write:package` PAT left base64-encoded in
|
||||
# ~/.docker/config.json is credential-at-rest on the most exposed host in the estate.
|
||||
# The LAN jobs above already `docker logout`; this one omitted it. security-review
|
||||
# 2026-08-15 finding 14.
|
||||
trap 'docker logout git.unom.io || true' EXIT
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
|
||||
cd ~/punktfunk-docs
|
||||
docker compose -f compose.production.yml pull docs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,9 +297,17 @@ jobs:
|
||||
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
|
||||
$ver = 'bun-v1.3.14'
|
||||
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
|
||||
# SHA-256 of this exact asset, pinned. GitHub release assets are MUTABLE at a fixed URL, so
|
||||
# the tag alone vouches for nothing — this binary is Authenticode-signed into our installer
|
||||
# and its hash published in the Ed25519 update manifest, i.e. our signature vouches for bytes
|
||||
# we downloaded. Verify them. On a bun bump, update BOTH $ver and $sha (compute:
|
||||
# `shasum -a 256 bun-windows-x64.zip`). security-review 2026-08-15 finding 12.
|
||||
$sha = '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
|
||||
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
|
||||
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip
|
||||
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||
if ($got -ne $sha) { throw "bun zip sha256 mismatch for ${ver}: got $got, pinned $sha" }
|
||||
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
|
||||
Expand-Archive -Path $zip -DestinationPath $dst -Force
|
||||
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
|
||||
|
||||
+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"
|
||||
|
||||
+341
-5
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.28.0"
|
||||
"version": "0.28.1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1903,6 +1903,97 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Update a native client's access",
|
||||
"description": "Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
|
||||
"operationId": "updateNativeClientAccess",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "fingerprint",
|
||||
"in": "path",
|
||||
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateNativeAccess"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Access updated; the stored record as now in force",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NativeClient"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set, or expires_in_secs together with clear_expiry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No paired native client with that fingerprint",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the trust store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Native host not enabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/native/pair": {
|
||||
@@ -1976,7 +2067,7 @@
|
||||
"native"
|
||||
],
|
||||
"summary": "Arm native pairing",
|
||||
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
|
||||
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
|
||||
"operationId": "armNativePairing",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -1999,6 +2090,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
@@ -2063,7 +2164,7 @@
|
||||
"native"
|
||||
],
|
||||
"summary": "Approve a pending device",
|
||||
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
|
||||
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
|
||||
"operationId": "approvePendingDevice",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -2090,7 +2191,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Device paired",
|
||||
"description": "Device paired; the stored record as now in force",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -2099,6 +2200,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Reserved grant bits set",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
@@ -4128,8 +4239,28 @@
|
||||
},
|
||||
"ApprovePending": {
|
||||
"type": "object",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
|
||||
"properties": {
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Access choice: grant bitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4144,6 +4275,16 @@
|
||||
"type": "object",
|
||||
"description": "Arm-native-pairing request body.",
|
||||
"properties": {
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4152,6 +4293,16 @@
|
||||
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
|
||||
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"ttl_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
@@ -5231,6 +5382,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
|
||||
"required": [
|
||||
"device",
|
||||
"grants",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Absolute expiry, host wall clock unix seconds; absent = permanent."
|
||||
},
|
||||
"grants": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.granted"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
|
||||
"required": [
|
||||
"device",
|
||||
"grants",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"grants": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.changed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
|
||||
"required": [
|
||||
"device",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceRef"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access.expired"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6497,10 +6733,44 @@
|
||||
"fingerprint"
|
||||
],
|
||||
"properties": {
|
||||
"access_level": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
|
||||
"example": "controller"
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Hex SHA-256 of the client certificate — its stable id here."
|
||||
},
|
||||
"granted_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When access was last granted, unix seconds — display/audit only, never enforced."
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Grant bitmask (`GRANT_*` bits 0–5). `null` = a record from before grants existed, which\nmeans full control.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name the client supplied when pairing.",
|
||||
@@ -6627,16 +6897,49 @@
|
||||
"age_secs"
|
||||
],
|
||||
"properties": {
|
||||
"access_level": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
|
||||
"example": "controller"
|
||||
},
|
||||
"age_secs": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Seconds since the device last knocked.",
|
||||
"minimum": 0
|
||||
},
|
||||
"expires_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Hex SHA-256 of the device's certificate — what approval pins."
|
||||
},
|
||||
"granted_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the stored record's access was granted (unix seconds). `null` when unknown."
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -7856,6 +8159,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateNativeAccess": {
|
||||
"type": "object",
|
||||
"description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
|
||||
"properties": {
|
||||
"clear_expiry": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)."
|
||||
},
|
||||
"expires_in_secs": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
|
||||
"example": 14400,
|
||||
"minimum": 0
|
||||
},
|
||||
"grants": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "New grant bitmask (`GRANT_*` bits 0–5); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
|
||||
"example": 1,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateResultInfo": {
|
||||
"type": "object",
|
||||
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
|
||||
dismissOnOutsideTap = false,
|
||||
) {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
|
||||
// user looking for a row the console does not show.
|
||||
val label = deviceName(LocalContext.current)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"Open the host's console (or web UI) and approve “$label”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -137,7 +137,8 @@ internal fun PairPinDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
val context = LocalContext.current
|
||||
var name by remember(pt) { mutableStateOf(deviceName(context)) }
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
|
||||
@@ -53,6 +53,11 @@ object ConnectErrors {
|
||||
"on the host."
|
||||
"wire-version" -> "Client and host versions don't match — update both to the same release."
|
||||
"busy" -> "The host is busy with another session."
|
||||
"access-expired" ->
|
||||
"Your access to this host has expired — ask the host's owner to grant it again."
|
||||
"launch-not-permitted" ->
|
||||
"This device's access doesn't include launching games — connect to the desktop, " +
|
||||
"or ask the host's owner."
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* The name the user knows this device by — what a host shows in its pending-approval list (the web
|
||||
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
|
||||
* under once approved.
|
||||
*
|
||||
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
|
||||
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
|
||||
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
|
||||
* identically on every unit of it — two of the same tablet pending approval are indistinguishable.
|
||||
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
|
||||
*/
|
||||
internal fun deviceName(context: Context): String {
|
||||
val userNamed = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
|
||||
}.getOrNull()
|
||||
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "Android"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
|
||||
var pairing by remember(pt) { mutableStateOf(false) }
|
||||
var err by remember(pt) { mutableStateOf<String?>(null) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
val context = LocalContext.current
|
||||
val name = remember(context) { deviceName(context) }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
@@ -82,8 +81,8 @@ suspend fun connectToHost(
|
||||
codecBits, preferredCodec, timeoutMs,
|
||||
launch,
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// user-set device name the pairing dialogs offer for nativePair.
|
||||
deviceName(context),
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
|
||||
@@ -34,6 +34,7 @@ import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.SessionAccess
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
@@ -86,6 +87,16 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var streamHandle: Long = 0L
|
||||
|
||||
/**
|
||||
* The active session's access-grant mask ([SessionAccess] bits) — set with [streamHandle] by
|
||||
* StreamScreen and kept live by its access poll; back to [SessionAccess.ALL] when the stream
|
||||
* leaves. Consulted only while streaming: the VK keyboard path below goes inert without
|
||||
* [SessionAccess.KEYBOARD] (the keys are consumed, not sent — the host would drop them, and
|
||||
* letting them fall through would drive Android navigation under a live stream). Courtesy
|
||||
* gating; the host enforces regardless.
|
||||
*/
|
||||
var streamAccess: Int = SessionAccess.ALL
|
||||
|
||||
/**
|
||||
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||
@@ -596,6 +607,10 @@ class MainActivity : ComponentActivity() {
|
||||
KeyEvent.ACTION_UP -> false
|
||||
else -> return super.dispatchKeyEvent(event)
|
||||
}
|
||||
// Without the KEYBOARD grant the key path is inert: consumed (so nothing
|
||||
// drives Android navigation under the stream) but never sent — the host
|
||||
// would drop it, and the Access chip is what says why. Courtesy gating.
|
||||
if (streamAccess and SessionAccess.KEYBOARD == 0) return true
|
||||
// Full-event overload: evdev scancode first (positional under ANY selected
|
||||
// physical-keyboard layout), keycode fallback — see Keymap docs.
|
||||
val vk = Keymap.toVk(event)
|
||||
|
||||
@@ -44,6 +44,18 @@ class MouseForwarder(
|
||||
var onRequestCapture: (() -> Unit)? = null
|
||||
var onReleaseCapture: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the POINTER grant ([io.unom.punktfunk.kit.SessionAccess.POINTER])
|
||||
* — seeded from the Welcome, kept live by StreamScreen's access poll. Without it the mouse
|
||||
* path goes inert: nothing forwards, and — the part that matters — the pointer is never
|
||||
* GRABBED, because a captured mouse that moves nothing is the "my mouse does nothing and
|
||||
* nobody says why" failure the grants UX exists to prevent (the Access chip says why
|
||||
* instead). Revocation mid-session releases an existing grab (StreamScreen calls [release]).
|
||||
* Volatile: set on the main thread, read wherever the dispatch path runs.
|
||||
*/
|
||||
@Volatile
|
||||
var pointerGranted: Boolean = true
|
||||
|
||||
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
|
||||
var captured = false
|
||||
private set
|
||||
@@ -59,6 +71,7 @@ class MouseForwarder(
|
||||
|
||||
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
|
||||
fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (captureWanted && !captured && !userReleased) {
|
||||
@@ -80,6 +93,7 @@ class MouseForwarder(
|
||||
|
||||
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
|
||||
fun onGenericMotion(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
|
||||
MotionEvent.ACTION_SCROLL -> wheel(ev)
|
||||
@@ -98,6 +112,7 @@ class MouseForwarder(
|
||||
* gesture layer is the touchpad story); returning false leaves those to the framework.
|
||||
*/
|
||||
fun onCapturedPointer(ev: MotionEvent): Boolean {
|
||||
if (!pointerGranted) return true // a revocation is racing the release of the grab
|
||||
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
@@ -131,7 +146,7 @@ class MouseForwarder(
|
||||
if (captured) {
|
||||
userReleased = true
|
||||
onReleaseCapture?.invoke()
|
||||
} else {
|
||||
} else if (pointerGranted) { // never grab a pointer whose input can't land
|
||||
userReleased = false
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
@@ -139,7 +154,7 @@ class MouseForwarder(
|
||||
|
||||
/** Auto-engage at stream start (setting on + a mouse actually present). */
|
||||
fun engageFromStart() {
|
||||
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
if (pointerGranted && captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
|
||||
onRequestCapture?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -204,7 +219,9 @@ class MouseForwarder(
|
||||
* input reader synthesizes them in), so both paths funnel into the same held-set and the
|
||||
* add/remove guard collapses the pair into a single wire press.
|
||||
*/
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) {
|
||||
if (pointerGranted) press(if (back) 4 else 5, down)
|
||||
}
|
||||
|
||||
private fun button(actionButton: Int, down: Boolean) {
|
||||
val b = when (actionButton) {
|
||||
|
||||
@@ -44,6 +44,21 @@ class RemotePointer(
|
||||
var active = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the POINTER grant — StreamScreen keeps it live from
|
||||
* the access poll. Ungranted, the SELECT long-press stops entering pointer mode (a mode whose
|
||||
* every action the host would drop; the Access chip says why), and a revocation while the
|
||||
* mode is on leaves it cleanly ([setGranted]). Everything else passes through untouched,
|
||||
* exactly as when the mode is off — the remote stays a remote.
|
||||
*/
|
||||
private var granted = true
|
||||
|
||||
/** Update the POINTER grant; revoking while pointer mode is on leaves the mode. Main thread. */
|
||||
fun setGranted(ok: Boolean) {
|
||||
granted = ok
|
||||
if (!ok && active) toggle()
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
|
||||
private var moveAccX = 0f
|
||||
@@ -169,6 +184,7 @@ class RemotePointer(
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
if (!active && !granted) return // never enter a mode whose input can't land
|
||||
active = !active
|
||||
if (!active) {
|
||||
held.clear()
|
||||
|
||||
@@ -70,6 +70,7 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionAccess
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
@@ -102,6 +103,20 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
|
||||
// The session's access level (the per-client grants of design/per-client-access.md), the
|
||||
// courtesy mirror of what the host enforces: seeded from the Welcome's advert here, kept live
|
||||
// by the 1 Hz poll below (the host's AccessUpdate messages fold latest-wins into the native
|
||||
// state). Full control + permanent — the only state an old host or an old native lib ever
|
||||
// reports — gates nothing and draws nothing: today's look, unchanged.
|
||||
val initialAccess = remember(handle) { NativeBridge.nativeAccessState(handle) }
|
||||
var accessGrants by remember(handle) {
|
||||
mutableStateOf(initialAccess?.getOrNull(0) ?: SessionAccess.ALL)
|
||||
}
|
||||
// Seconds until this session's access expires (0 = permanent), as last reported natively.
|
||||
var accessRemaining by remember(handle) {
|
||||
mutableStateOf(initialAccess?.getOrNull(1) ?: 0)
|
||||
}
|
||||
|
||||
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
|
||||
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
@@ -182,6 +197,34 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
NativeBridge.nativeSetMicMuted(handle, muted)
|
||||
}
|
||||
|
||||
// Push a grant mask into every gate that consults one — called at session start (once the
|
||||
// router/forwarders exist) and again whenever the poll sees the mask change (an AccessUpdate
|
||||
// revoked or restored something mid-session). A lambda, deliberately not a local fun — this
|
||||
// codebase has been burned by `::localFun` references in composable scopes. The gates it does
|
||||
// NOT reach (the Compose-side ones — the touch layer, the IME summon, the banner line, the
|
||||
// chip) key on `accessGrants` directly and re-run on the state write.
|
||||
val applyAccess: (Int) -> Unit = { grants ->
|
||||
activity?.streamAccess = grants
|
||||
activity?.gamepadRouter?.gamepadGranted = grants and SessionAccess.GAMEPAD != 0
|
||||
val pointerOk = grants and SessionAccess.POINTER != 0
|
||||
activity?.mouseForwarder?.let { m ->
|
||||
m.pointerGranted = pointerOk
|
||||
// A revocation must also let an existing grab go (and lift held buttons): a captured
|
||||
// mouse that moves nothing reads as a broken mouse, not a spectator session.
|
||||
if (!pointerOk) m.release()
|
||||
}
|
||||
activity?.remotePointer?.setGranted(pointerOk)
|
||||
// Mic revoked mid-session: stop the capture — the host detaches its end regardless, and
|
||||
// an open mic (with the platform's recording indicator lit) feeding a plane the host
|
||||
// drops would be the worst kind of lie. Not restarted on a re-grant: the host attaches
|
||||
// the mic service at session setup only, so a fresh session is the honest offer.
|
||||
if (grants and SessionAccess.MIC == 0 && micRunning) {
|
||||
releaseMicEffects(micEffects)
|
||||
NativeBridge.nativeStopMic(handle)
|
||||
micRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
|
||||
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
|
||||
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
|
||||
@@ -243,22 +286,62 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
|
||||
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
|
||||
LaunchedEffect(handle) {
|
||||
var lastAccessSeq = initialAccess?.getOrNull(2) ?: 0
|
||||
while (true) {
|
||||
delay(1000)
|
||||
// Access first, ended second: a session about to close on its expiry gets its final
|
||||
// countdown read, which is what lets the ended branch word that close honestly.
|
||||
NativeBridge.nativeAccessState(handle)?.let { st ->
|
||||
val grants = st.getOrNull(0) ?: SessionAccess.ALL
|
||||
val seq = st.getOrNull(2) ?: 0
|
||||
if (grants != accessGrants) {
|
||||
accessGrants = grants
|
||||
applyAccess(grants)
|
||||
}
|
||||
accessRemaining = st.getOrNull(1) ?: 0
|
||||
if (seq != lastAccessSeq) {
|
||||
lastAccessSeq = seq
|
||||
// A fresh AccessUpdate close to the deadline is the host's T−5 m / T−1 m
|
||||
// courtesy warning — surface it. Grant edits (and a warning's grant echo)
|
||||
// otherwise just move the chip; a toast per edit would be noise.
|
||||
if (accessRemaining in 1..330) {
|
||||
val mins = (accessRemaining + 30) / 60
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (mins <= 1) {
|
||||
"Access expires in about a minute."
|
||||
} else {
|
||||
"Access expires in about $mins minutes."
|
||||
},
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
// WHY it ended decides what the user is told. This used to show the "host may be
|
||||
// asleep" line for EVERY ending — including a game the player had just quit and a
|
||||
// session the host ended on purpose — which reads as a failure report for
|
||||
// something nobody did wrong. Only a connection that actually died says that now.
|
||||
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
|
||||
when (reason) {
|
||||
SessionEndReason.LOST ->
|
||||
when {
|
||||
// The session died inside the access countdown's final stretch: that IS the
|
||||
// typed expiry close (ACCESS_EXPIRED), worded with the shared rejection
|
||||
// sentence rather than the generic host-ended silence. Recognized off the
|
||||
// countdown because the generic end-reason byte predates the expiry code.
|
||||
accessRemaining in 1..75 ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Your access to this host has expired.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
reason == SessionEndReason.LOST ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
SessionEndReason.HOST_ERROR ->
|
||||
reason == SessionEndReason.HOST_ERROR ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"The host ended the session with an error.",
|
||||
@@ -266,10 +349,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
).show()
|
||||
// Deliberate endings — the player quit the game, the host was stopped, or we
|
||||
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
|
||||
SessionEndReason.GAME_EXITED,
|
||||
SessionEndReason.HOST_ENDED,
|
||||
SessionEndReason.LOCAL,
|
||||
SessionEndReason.NONE -> {}
|
||||
else -> {}
|
||||
}
|
||||
onSessionEnded(reason)
|
||||
return@LaunchedEffect
|
||||
@@ -465,15 +545,32 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
handle,
|
||||
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
|
||||
onActiveChanged = { on -> remotePointerOn = on },
|
||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
||||
// The toggle TYPES — summoning also needs the KEYBOARD grant (hiding is free).
|
||||
onKeyboardToggle = {
|
||||
keyCapture?.let { v ->
|
||||
if (v.imeShown || accessGrants and SessionAccess.KEYBOARD != 0) {
|
||||
v.setImeVisible(!v.imeShown)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activity?.remotePointer = remote
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the host has a
|
||||
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
|
||||
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
|
||||
// Everything the grant gates hang off now exists — apply the session's access level once
|
||||
// up front (the poll only re-applies on change, and a restricted session is restricted
|
||||
// from its first event, not from its first poll).
|
||||
applyAccess(accessGrants)
|
||||
// Shared clipboard (text v1): only when the user setting is on AND the session's access
|
||||
// includes the clipboard AND the host has a working clipboard service. Ungranted, the
|
||||
// host's policy resolution declines everything anyway (grants AND into it); not starting
|
||||
// the sync is the client-side mirror — no offers announced, no poll thread for a plane
|
||||
// that cannot move. Applied at session start only, like the host's own coordinator gate.
|
||||
val clip = if (session.clipboardSync &&
|
||||
accessGrants and SessionAccess.CLIPBOARD != 0 &&
|
||||
NativeBridge.nativeClipSupported(handle)
|
||||
) {
|
||||
ClipboardSync(context, handle).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
@@ -699,6 +796,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
activity?.remotePointer = null
|
||||
decor?.pointerIcon = priorPointerIcon
|
||||
activity?.streamHandle = 0L
|
||||
activity?.streamAccess = SessionAccess.ALL // grants are per session, like the handle
|
||||
activity?.requestStreamExit = null
|
||||
// Back in the menus: the SC2 (if present) resumes driving the console UI.
|
||||
activity?.startSc2MenuNav()
|
||||
@@ -817,7 +915,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
|
||||
if (micWanted) {
|
||||
// The MIC grant is read live (a surface recreate re-runs this, and
|
||||
// the mask may have changed since the last one): without it no
|
||||
// capture opens — the host never attached this session to its mic
|
||||
// service, so the platform's recording indicator would announce a
|
||||
// mic nobody can hear.
|
||||
if (micWanted && accessGrants and SessionAccess.MIC != 0) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
if (initialSettings.echoCancel) {
|
||||
@@ -881,6 +984,22 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
)
|
||||
}
|
||||
}
|
||||
// The Access chip — what this session is allowed to do, said in the preset vocabulary
|
||||
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
|
||||
// no expiry — every session against an old host, and most against a new one — shows
|
||||
// NOTHING: the chip exists for the sessions where input silently not landing needs an
|
||||
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
|
||||
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
|
||||
val accessChip = when {
|
||||
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
|
||||
accessRemaining > 0 ->
|
||||
"${SessionAccess.label(accessGrants)} · " +
|
||||
"${SessionAccess.remainingLabel(accessRemaining)} left"
|
||||
else -> SessionAccess.label(accessGrants)
|
||||
}
|
||||
if (accessChip != null) {
|
||||
AccessChip(accessChip, Modifier.align(Alignment.TopEnd).padding(12.dp))
|
||||
}
|
||||
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
|
||||
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
|
||||
// broken. Purely visual; it sits above the video and below the gesture layer.
|
||||
@@ -898,7 +1017,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
|
||||
//
|
||||
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
|
||||
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// TopEnd the Access chip, TopCentre the three transient cues — but MotionUnreachableHint
|
||||
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
|
||||
// stacking or sliding off-centre: the notice reports something broken about THIS session
|
||||
// and names the setting that fixes it, while the banner repeats shortcuts that will be
|
||||
@@ -919,8 +1038,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// button — all land on the same BackHandler).
|
||||
add("Back leaves the stream")
|
||||
// The tap lives in the pointer touch models only — passthrough gives every
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on.
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
|
||||
// finger to the host verbatim — and needs a screen to put three fingers on,
|
||||
// plus the POINTER grant (without it the gesture layer is not installed).
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH &&
|
||||
accessGrants and SessionAccess.POINTER != 0
|
||||
) {
|
||||
add("three-finger tap for stats")
|
||||
}
|
||||
}
|
||||
}.joinToString(" · "),
|
||||
alpha = bannerAlpha,
|
||||
@@ -951,23 +1075,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
|
||||
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
|
||||
// stationary held stroke alive (and its cancellation lifts everything on teardown).
|
||||
val stylus = remember(handle) {
|
||||
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
// The POINTER grant gates the whole touch/stylus capture layer — "don't capture what
|
||||
// can't land": ungranted, no gesture handler is installed at all (and no pen lane opens),
|
||||
// rather than fingers being read into events the host will drop. Keyed on the grant so an
|
||||
// AccessUpdate flipping it mid-session swaps the layer live.
|
||||
val pointerOk = accessGrants and SessionAccess.POINTER != 0
|
||||
val stylus = remember(handle, pointerOk) {
|
||||
if (pointerOk && NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
|
||||
}
|
||||
if (stylus != null) {
|
||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
||||
}
|
||||
Box(
|
||||
videoFit.pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
videoFit.pointerInput(handle, touchMode, pointerOk) {
|
||||
when {
|
||||
!pointerOk -> {} // no capture — the Access chip is what says why
|
||||
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
else -> streamTouchInput(
|
||||
handle,
|
||||
stylus,
|
||||
trackpad = touchMode == TouchMode.TRACKPAD,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
onCycleStats = { statsVerbosity = statsVerbosity.next() },
|
||||
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
|
||||
// The summon rides the pointer gesture but TYPES — so it also needs the
|
||||
// KEYBOARD grant (dismissing is always allowed).
|
||||
onKeyboard = { show ->
|
||||
if (!show || accessGrants and SessionAccess.KEYBOARD != 0) {
|
||||
keyCapture?.setImeVisible(show)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1031,6 +1167,25 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing Access chip — the session's access level in the preset vocabulary, with the live
|
||||
* countdown when the grant expires ("Controller only · 1 h 58 m left"). Same pill family as the
|
||||
* other in-stream overlays, sized down a step because it stands for the whole session rather than
|
||||
* flashing a moment's confirmation. Only composed when there is something to say: a full-control
|
||||
* permanent session — today's normal — shows nothing at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccessChip(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion
|
||||
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,9 +105,12 @@ class DeviceGyro(
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
// The write gate, per sample: sends must be on at all (the forwarding
|
||||
// preference AND the session's GAMEPAD grant — an AccessUpdate can revoke it
|
||||
// mid-session), pad 0 must exist (motion never creates a pad) and must not be
|
||||
// a capture link's (its own IMU is streaming).
|
||||
val write = router.sendsEnabled() && router.padPresent(0) &&
|
||||
!router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
|
||||
@@ -51,7 +51,7 @@ class GamepadRouter(
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
@@ -70,6 +70,25 @@ class GamepadRouter(
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** The ctor's forwarding preference, fixed for the session — one term of [forwarding]. */
|
||||
private val forwardingSetting = forwarding
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the GAMEPAD grant ([SessionAccess.GAMEPAD]) —
|
||||
* seeded from the Welcome and kept live by `StreamScreen`'s access poll (an `AccessUpdate`
|
||||
* can revoke or restore it mid-session, latest-wins). Gates exactly what the forwarding
|
||||
* preference gates: the wire sends, never the slots — the exit/mic/stats chords must keep
|
||||
* working on a Controller-less access level too (they are local controls that happen to be
|
||||
* read off pad buttons). The host enforces regardless; this stops the client paying to send
|
||||
* events that will be dropped. Volatile: the sensor and USB-capture threads read it per
|
||||
* sample through [forwarding].
|
||||
*/
|
||||
@Volatile
|
||||
var gamepadGranted: Boolean = true
|
||||
|
||||
/** Send on the wire at all — the forwarding preference AND the session's GAMEPAD grant. */
|
||||
private val forwarding: Boolean get() = forwardingSetting && gamepadGranted
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
@@ -380,6 +399,13 @@ class GamepadRouter(
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire sends are on at all — the forwarding preference AND the session's GAMEPAD
|
||||
* grant. For the writers that ride the pad planes from OUTSIDE this router (the phone-gyro
|
||||
* mirror), which must stand down with it. Read from the sensor thread.
|
||||
*/
|
||||
fun sendsEnabled(): Boolean = forwarding
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
|
||||
@@ -99,6 +99,17 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* The session's live access state as `[grants, remainingSecs, updateSeq]`, or `null` on a `0`
|
||||
* handle. `grants` is a [SessionAccess] bitmask; `remainingSecs` counts down to the access
|
||||
* expiry (`0` = permanent); `updateSeq` increments once per `AccessUpdate` the host sent
|
||||
* (latest-wins — the state IS the fold, this counter is how a poller tells a fresh T−5 m /
|
||||
* T−1 m warning arrived and owes a toast). Seeded from the Welcome's access advert; an old
|
||||
* host — or an old native lib — reads as full control, permanent, exactly what such a host
|
||||
* enforces. Poll ~1 Hz alongside [nativeSessionEnded]. Cheap; safe on the UI thread.
|
||||
*/
|
||||
external fun nativeAccessState(handle: Long): IntArray?
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Per-client access grants — the Kotlin mirror of `punktfunk_core::quic::access` (bit-for-bit;
|
||||
* `design/per-client-access.md` §3), read per session via [NativeBridge.nativeAccessState].
|
||||
*
|
||||
* The host is the only enforcer: everything gated on these bits client-side is courtesy UX over
|
||||
* the same vocabulary — don't capture what can't land (a keyboard that silently does nothing is
|
||||
* the failure mode this exists to prevent), and say what this session is (the stream's Access
|
||||
* chip). The user-facing word is **"Access"**; the preset labels are *derived* from the mask,
|
||||
* never stored, so they can't drift from what the host actually granted.
|
||||
*/
|
||||
object SessionAccess {
|
||||
/** Controller input — gamepad events, rich pad input, pad audio, rumble return. */
|
||||
const val GAMEPAD = 1 shl 0
|
||||
|
||||
/** Pointing input — mouse rel/abs + buttons, scroll, touch, and the pen plane. */
|
||||
const val POINTER = 1 shl 1
|
||||
|
||||
/** Key input — key down/up and IME-committed text. */
|
||||
const val KEYBOARD = 1 shl 2
|
||||
|
||||
/** Shared clipboard (ANDed into the host's clipboard policy). */
|
||||
const val CLIPBOARD = 1 shl 3
|
||||
|
||||
/** Mic injection — the uplink plane + the per-session mic attach. */
|
||||
const val MIC = 1 shl 4
|
||||
|
||||
/** Library launch (`Hello.launch`). */
|
||||
const val LAUNCH = 1 shl 5
|
||||
|
||||
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
|
||||
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
|
||||
|
||||
/**
|
||||
* The preset name a mask displays as — §3.2's rule: three levels people actually reason
|
||||
* about, "Custom" for any other combination, never a raw bit list.
|
||||
*/
|
||||
fun label(grants: Int): String = when (grants and ALL) {
|
||||
ALL -> "Full control"
|
||||
GAMEPAD -> "Controller only"
|
||||
0 -> "View only"
|
||||
else -> "Custom"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact time-left wording for the Access chip ("1 h 58 m", "12 m", "45 s") — hours and
|
||||
* minutes once the span has them, bare seconds only under a minute (the final countdown).
|
||||
*/
|
||||
fun remainingLabel(secs: Int): String {
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
return when {
|
||||
h > 0 && m > 0 -> "$h h $m m"
|
||||
h > 0 -> "$h h"
|
||||
m > 0 -> "$m m"
|
||||
else -> "${secs.coerceAtLeast(0)} s"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [SessionAccess] — the bit values are an ABI contract with
|
||||
* `punktfunk_core::quic::access` (wire == store == this mirror), and the preset labels are the
|
||||
* §3.2 naming rule the Access chip renders from: three levels people reason about, "Custom" for
|
||||
* anything else, derived from the mask so they cannot drift. Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class SessionAccessTest {
|
||||
|
||||
/** Bit-for-bit the core vocabulary — a reorder here would mislabel every session. */
|
||||
@Test
|
||||
fun `bits mirror punktfunk-core`() {
|
||||
assertEquals(1, SessionAccess.GAMEPAD)
|
||||
assertEquals(2, SessionAccess.POINTER)
|
||||
assertEquals(4, SessionAccess.KEYBOARD)
|
||||
assertEquals(8, SessionAccess.CLIPBOARD)
|
||||
assertEquals(16, SessionAccess.MIC)
|
||||
assertEquals(32, SessionAccess.LAUNCH)
|
||||
assertEquals(0x3F, SessionAccess.ALL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preset labels derive from the mask`() {
|
||||
assertEquals("Full control", SessionAccess.label(SessionAccess.ALL))
|
||||
assertEquals("Controller only", SessionAccess.label(SessionAccess.GAMEPAD))
|
||||
assertEquals("View only", SessionAccess.label(0))
|
||||
// Any other combination is Custom — including controller + clipboard, the design's
|
||||
// media-remote example.
|
||||
assertEquals(
|
||||
"Custom",
|
||||
SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD),
|
||||
)
|
||||
assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remaining label is compact and never empty`() {
|
||||
assertEquals("1 h 58 m", SessionAccess.remainingLabel(7130))
|
||||
assertEquals("2 h", SessionAccess.remainingLabel(7200))
|
||||
assertEquals("12 m", SessionAccess.remainingLabel(725))
|
||||
assertEquals("45 s", SessionAccess.remainingLabel(45))
|
||||
assertEquals("0 s", SessionAccess.remainingLabel(0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The session's access level over JNI — the Android leg of `design/per-client-access.md` §7.
|
||||
//!
|
||||
//! One poll shim: Kotlin reads `[grants, remainingSecs, updateSeq]` ~1 Hz (alongside its
|
||||
//! session-ended watchdog) instead of holding a blocking event thread — access news is a
|
||||
//! console edit or an expiry warning, a handful per session, and every gate the mask drives
|
||||
//! re-checks within a second anyway. The connector already folds each mid-session
|
||||
//! [`punktfunk_core::quic::AccessUpdate`] latest-wins into its live grants/deadline slots;
|
||||
//! the seq counter here only exists so the poller can tell a FRESH update arrived (the host's
|
||||
//! T−5 m / T−1 m warnings owe a toast) without diffing state that a warning doesn't change.
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JIntArray, JObject};
|
||||
use jni::sys::jlong;
|
||||
use jni::EnvUnowned;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::SessionHandle;
|
||||
|
||||
/// `NativeBridge.nativeAccessState(handle): IntArray?` — the live access state as
|
||||
/// `[grants, remainingSecs, updateSeq]`; `null` on a `0` handle. `grants` is the
|
||||
/// `GRANT_GAMEPAD`-family bitmask, seeded from the Welcome's advert (an old host reads as
|
||||
/// `GRANT_ALL` — full control, today's behavior); `remainingSecs` counts down to the access
|
||||
/// deadline on the CLIENT's clock (`0` = permanent, clamped to ≥ 1 once a deadline exists so
|
||||
/// the sentinel can never be reached by counting); `updateSeq` increments once per
|
||||
/// `AccessUpdate` drained from the connector's event plane. Not android-gated — pure `jni` +
|
||||
/// connector reads, so it links on the host build too. Cheap; safe on the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeAccessState<'local>(
|
||||
mut env: EnvUnowned<'local>,
|
||||
_this: JObject<'local>,
|
||||
handle: jlong,
|
||||
) -> JIntArray<'local> {
|
||||
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
|
||||
if handle == 0 {
|
||||
return Ok(JIntArray::default());
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Drain the event plane into the seq counter. The connector's grants/deadline slots
|
||||
// are already the latest-wins fold when an event lands — the events carry no state
|
||||
// this read doesn't get below, they are purely the "something arrived" cue. Zero
|
||||
// timeout: this is the UI thread's poll, it must never park.
|
||||
while h.client.next_access_update(Duration::ZERO).is_ok() {
|
||||
h.access_seq.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let remaining: u64 = match h.client.access_deadline_unix() {
|
||||
None => 0, // permanent
|
||||
Some(deadline) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
// ≥ 1 once a deadline exists: 0 is the "permanent" sentinel, and a deadline
|
||||
// already past with the session still up (the host's typed close is in
|
||||
// flight) must keep reading as "about to end", never flip to "forever".
|
||||
deadline.saturating_sub(now).max(1)
|
||||
}
|
||||
};
|
||||
let buf: [i32; 3] = [
|
||||
h.client.access_grants() as i32,
|
||||
remaining.min(i32::MAX as u64) as i32,
|
||||
h.access_seq.load(Ordering::Relaxed) as i32,
|
||||
];
|
||||
let arr = env.new_int_array(buf.len())?;
|
||||
arr.set_region(env, 0, &buf)?;
|
||||
Ok(arr)
|
||||
})
|
||||
.resolve::<LogErrorAndDefault>()
|
||||
}
|
||||
@@ -315,6 +315,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
access_seq: std::sync::atomic::AtomicU32::new(0),
|
||||
};
|
||||
Box::into_raw(Box::new(handle)) as jlong
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
|
||||
//! renegotiation. Port the remaining orchestration from `clients/linux`.
|
||||
|
||||
mod access;
|
||||
mod clipboard;
|
||||
mod connect;
|
||||
mod input;
|
||||
@@ -25,7 +26,7 @@ mod probe;
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -82,6 +83,10 @@ pub(crate) struct SessionHandle {
|
||||
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
|
||||
/// starts unmuted.
|
||||
pub mic_muted: Arc<AtomicBool>,
|
||||
/// Count of `AccessUpdate`s drained from the connector's event plane, bumped by the
|
||||
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
|
||||
/// (the host's expiry warnings) arrived without holding a blocking event thread.
|
||||
pub(crate) access_seq: AtomicU32,
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
|
||||
@@ -378,7 +378,11 @@ struct ContentView: View {
|
||||
#if !os(tvOS)
|
||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||
isStreaming: model.connection != nil,
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
// Host cap AND this device's CLIPBOARD grant (per-client access §7) — an
|
||||
// ungranted session's menu item greys out instead of inviting a refused enable.
|
||||
clipboardAvailable: model.connection.map {
|
||||
$0.hostSupportsClipboard && $0.canUseClipboard
|
||||
} == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
micAvailable: model.micAvailable,
|
||||
@@ -1063,7 +1067,25 @@ struct ContentView: View {
|
||||
MotionUnreachableBadge()
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// The expiry-warning toast (T−5 m / T−1 m, per-client access §7) —
|
||||
// transient, every platform, every tier: "the pad just died" must
|
||||
// read as "the evening's access ended" while it can still be fixed.
|
||||
if captureEnabled, let warning = model.accessWarning {
|
||||
AccessWarningBadge(text: warning)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The access chip — up for the life of a LIMITED session ("Controller
|
||||
// only · ends in 1 h 58 m"), at every tier and with the overlay off.
|
||||
// Never mounted for a full-and-permanent session (every old host):
|
||||
// today's look must not change there. tvOS states it as a line in the
|
||||
// stats overlay instead (StreamHUDView).
|
||||
if captureEnabled && model.accessLimited {
|
||||
AccessChipBadge(
|
||||
label: model.accessLevel.label,
|
||||
remainingSecs: model.accessRemainingSecs)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
if captureEnabled && model.micMuted {
|
||||
@@ -1083,6 +1105,8 @@ struct ContentView: View {
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessWarning)
|
||||
.animation(.easeOut(duration: 0.2), value: model.accessLimited)
|
||||
}
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
|
||||
@@ -551,8 +551,10 @@ struct GamepadHomeView: View {
|
||||
filled: true,
|
||||
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
|
||||
// this card's connect with a title picked first, not a host-level action like
|
||||
// wake or forget.
|
||||
hasLibrary: true,
|
||||
// wake or forget. Gated on a pinned identity: the library plane's MgmtTransport
|
||||
// accepts any cert for an unpinned host, so an unpaired host must not expose a
|
||||
// library affordance a LAN MITM could answer. security-review 2026-08-15 #8.
|
||||
hasLibrary: host.pinnedSHA256 != nil,
|
||||
osChain: host.osChain,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !online && !host.wakeMacs.isEmpty,
|
||||
|
||||
@@ -268,7 +268,12 @@ struct HomeView: View {
|
||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
||||
// …and browsing is that same connect with a title picked first, so a pinned card opens its
|
||||
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled
|
||||
// Gated on a pinned identity, not just the feature toggle: the library plane's
|
||||
// MgmtTransport trust-on-first-use accepts ANY cert for a pin-less host (self-signed, no
|
||||
// SAN — system trust is bypassed), so browsing an unpinned host lets a LAN MITM serve a
|
||||
// forged catalog and harvest the device's pairing identity. Pair first, exactly as the
|
||||
// stream path already refuses an unpinned connect. security-review 2026-08-15 finding 8.
|
||||
let onBrowseLibrary: (() -> Void)? = (libraryEnabled && host.pinnedSHA256 != nil)
|
||||
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
|
||||
: nil
|
||||
return HostCardView(
|
||||
|
||||
@@ -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
|
||||
@@ -357,6 +357,17 @@ struct LibraryView: View {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
// Beyond the client identity, require the HOST's pinned fingerprint. MgmtTransport accepts
|
||||
// ANY cert for a pin-less host (self-signed, no SAN → system trust is bypassed), so browsing
|
||||
// one lets a LAN MITM serve a forged catalog and harvest this device's mTLS identity. A host
|
||||
// can hold a client identity yet no host pin (abandoned pairing, or after "Forget
|
||||
// Identity"), so this is a distinct check. security-review 2026-08-15 finding 8.
|
||||
guard current.pinnedSHA256 != nil else {
|
||||
games = []
|
||||
errorText = "Pair with this host before browsing its library."
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
@@ -409,7 +420,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
|
||||
@@ -262,6 +262,27 @@ final class SessionModel: ObservableObject {
|
||||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||||
@Published private(set) var clipboardReason: UInt8 = 0
|
||||
|
||||
// MARK: - Per-client access (design/per-client-access.md §7)
|
||||
|
||||
/// The session's access preset, derived live from the grants mask (§3.2 — the label is
|
||||
/// never stored). `.fullControl` against every old host and for every full-grant device,
|
||||
/// so nothing below changes today's look there.
|
||||
@Published private(set) var accessLevel: PunktfunkConnection.AccessLevel = .fullControl
|
||||
/// Seconds until this session's access expires; `0` = permanent. Ticks down at the 1 Hz
|
||||
/// stats cadence — the chip's countdown renders straight from it.
|
||||
@Published private(set) var accessRemainingSecs: UInt32 = 0
|
||||
/// Anything about this session's access differs from full-and-permanent — the visibility
|
||||
/// gate for the chip (and the tvOS stats-overlay line). False = today's look, untouched.
|
||||
@Published private(set) var accessLimited = false
|
||||
/// The transient expiry-warning toast ("Access ends in 5 m") — non-nil for a few seconds
|
||||
/// around the T−5 m / T−1 m marks the host also warns at via `AccessUpdate`.
|
||||
@Published private(set) var accessWarning: String?
|
||||
/// One-shot latches for the two warning marks (reset per session).
|
||||
private var accessWarned5m = false
|
||||
private var accessWarned1m = false
|
||||
/// Auto-dismiss for `accessWarning` — held so a newer warning replaces a pending clear.
|
||||
private var accessWarningTimer: Task<Void, Never>?
|
||||
#if os(tvOS)
|
||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||
@@ -566,7 +587,9 @@ final class SessionModel: ObservableObject {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone — SessionAudio never opens an uplink either
|
||||
#else
|
||||
guard settings.micEnabled else { return false }
|
||||
// The session's grants must include MIC (per-client access §7 — hide the mic UI when
|
||||
// ungranted; a mute button over a mic the host drops would be a lie twice over).
|
||||
guard settings.micEnabled, connection?.canUseMic != false else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false // denied / restricted — there is no uplink to mute
|
||||
@@ -613,6 +636,71 @@ final class SessionModel: ObservableObject {
|
||||
audio?.setMicMuted(micMuted || isBackgrounded)
|
||||
}
|
||||
|
||||
// MARK: - Per-client access (chip state + expiry warnings)
|
||||
|
||||
/// Refresh the published access state from the connection's LIVE grants + countdown —
|
||||
/// called by the 1 Hz stats tick, which is also what makes a mid-session `AccessUpdate`
|
||||
/// (a console edit) reach the chip and the capture gates within a second. The equality
|
||||
/// guards keep a full-and-permanent session (every old host) from publishing anything.
|
||||
private func updateAccessState() {
|
||||
guard let conn = connection else { return }
|
||||
let grants = conn.accessGrants
|
||||
let level = PunktfunkConnection.AccessLevel(grants: grants)
|
||||
let remaining = conn.accessExpiresInSeconds
|
||||
if accessLevel != level { accessLevel = level }
|
||||
if accessRemainingSecs != remaining { accessRemainingSecs = remaining }
|
||||
let limited = level != .fullControl || remaining != 0
|
||||
if accessLimited != limited { accessLimited = limited }
|
||||
// A mid-session edit that removed BOTH input classes releases an engaged capture:
|
||||
// holding a frozen cursor and swallowed keys over input the host now drops is
|
||||
// exactly the "keyboard does nothing and nobody says why" failure §7 exists to
|
||||
// prevent. (Engage is gated at the stream views; this is the live-revoke half.)
|
||||
if mouseCaptured,
|
||||
grants & (PunktfunkConnection.grantPointer | PunktfunkConnection.grantKeyboard) == 0 {
|
||||
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
|
||||
}
|
||||
// The T−5 m / T−1 m warning toasts (§7). Derived from the countdown CROSSING the
|
||||
// marks rather than from the AccessUpdate messages alone: the host's warnings
|
||||
// re-anchor the same countdown, so this shows them when they arrive AND still fires
|
||||
// on plain clock progress if a warning datagram never lands. One shot each; an edit
|
||||
// that extends the deadline back above a mark re-arms it.
|
||||
guard remaining != 0 else { return }
|
||||
if remaining > 300 {
|
||||
accessWarned5m = false
|
||||
accessWarned1m = false
|
||||
} else if remaining > 60 {
|
||||
accessWarned1m = false
|
||||
if !accessWarned5m {
|
||||
accessWarned5m = true
|
||||
showAccessWarning("Access ends in \(Self.accessCountdown(remaining))")
|
||||
}
|
||||
} else if !accessWarned1m {
|
||||
accessWarned1m = true
|
||||
accessWarned5m = true
|
||||
showAccessWarning("Access ends in under a minute")
|
||||
}
|
||||
}
|
||||
|
||||
/// Put one warning toast up for a few seconds (the motion hint's pattern: last one wins,
|
||||
/// its timer restarts, teardown cancels a pending clear).
|
||||
private func showAccessWarning(_ text: String) {
|
||||
accessWarning = text
|
||||
accessWarningTimer?.cancel()
|
||||
accessWarningTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.accessWarning = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 h 58 m" / "12 m" / "45 s" — the countdown wording the chip and the warnings share.
|
||||
static func accessCountdown(_ secs: UInt32) -> String {
|
||||
let s = Int(secs)
|
||||
if s >= 3600 { return "\(s / 3600) h \((s % 3600) / 60) m" }
|
||||
if s >= 60 { return "\(s / 60) m" }
|
||||
return "\(s) s"
|
||||
}
|
||||
|
||||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||||
@@ -658,6 +746,16 @@ final class SessionModel: ObservableObject {
|
||||
motionHintTimer?.cancel()
|
||||
motionHintTimer = nil
|
||||
motionUnreachableKind = nil
|
||||
// Access state is per-session: back to the invisible full-and-permanent default, and
|
||||
// no warning latch may carry into the next stream (same discipline as the mic mute).
|
||||
accessWarningTimer?.cancel()
|
||||
accessWarningTimer = nil
|
||||
accessWarning = nil
|
||||
accessLevel = .fullControl
|
||||
accessRemainingSecs = 0
|
||||
accessLimited = false
|
||||
accessWarned5m = false
|
||||
accessWarned1m = false
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
@@ -732,6 +830,10 @@ final class SessionModel: ObservableObject {
|
||||
let name = activeHost?.displayName ?? "host"
|
||||
// WHY it ended, asked while the connection is still up — `disconnect` tears it down.
|
||||
let reason = conn.sessionEndReason
|
||||
// A typed mid-session rejection outranks the coarse reason: an access-expiry close
|
||||
// (per-client access §4) files under `.hostError` there, and "ended with an error"
|
||||
// is the wrong sentence for "your access expired".
|
||||
let rejection = conn.endRejection
|
||||
// Where a game exit sends us: back into the library this title was launched from, so the
|
||||
// next one is a tap away. Only for a launch that CAME from the library — a game exiting in
|
||||
// a plain desktop session has no library to return to.
|
||||
@@ -741,6 +843,11 @@ final class SessionModel: ObservableObject {
|
||||
// without naming one, which is what that launch effectively browsed.
|
||||
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
if let rejection {
|
||||
// The shared typed-rejection wording ("Your access to this host has expired…").
|
||||
errorMessage = "\(name): \(rejection.userMessage)"
|
||||
return
|
||||
}
|
||||
switch reason {
|
||||
case .gameExited:
|
||||
// The player quit their own game. Not a failure, and they are probably after the next
|
||||
@@ -795,7 +902,9 @@ final class SessionModel: ObservableObject {
|
||||
speakerUID: settings.speakerUID,
|
||||
micUID: settings.micUID,
|
||||
micChannel: settings.micChannel,
|
||||
micEnabled: settings.micEnabled,
|
||||
// Deny-at-setup for an ungranted mic (per-client access §5): no MIC bit, no
|
||||
// uplink at all — a capture the host would only drop is pure privacy downside.
|
||||
micEnabled: settings.micEnabled && conn.canUseMic,
|
||||
echoCancel: settings.echoCancel,
|
||||
// The A/V sync reference: `endToEnd` is capture→on-glass, the one figure that says
|
||||
// where the picture actually IS, and the audio ring steers its depth to land with it.
|
||||
@@ -833,9 +942,11 @@ final class SessionModel: ObservableObject {
|
||||
gamepadFeedback = feedback
|
||||
#if os(macOS)
|
||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
||||
// hosts never see a ClipControl) AND granted to this device (per-client access §5 —
|
||||
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
|
||||
// asking keeps the UI honest). Same trust gate as audio — nothing is announced
|
||||
// during the trust prompt.
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard, conn.canUseClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
@@ -875,7 +986,7 @@ final class SessionModel: ObservableObject {
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
Task.detached { sync.stop() }
|
||||
} else if conn.hostSupportsClipboard {
|
||||
} else if conn.hostSupportsClipboard, conn.canUseClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
@@ -892,6 +1003,9 @@ final class SessionModel: ObservableObject {
|
||||
// success; this only fires after the timeout.
|
||||
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
|
||||
self.resizing = self.resizeIndicator.active
|
||||
// Access chip + expiry warnings: the same tick that drives every other live
|
||||
// readout also walks the countdown and picks up mid-session grant edits.
|
||||
self.updateAccessState()
|
||||
let (frames, bytes, total) = self.meter.drain()
|
||||
self.fps = frames
|
||||
self.mbps = Double(bytes) * 8 / 1_000_000
|
||||
|
||||
@@ -96,6 +96,21 @@ struct StreamHUDView: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// The session's access level (per-client access §7). tvOS carries it HERE, as a
|
||||
// stats-overlay line, instead of the floating chip the pointer platforms wear — a
|
||||
// couch surface where every extra overlay competes with the picture keeps the
|
||||
// fact with the other session facts. Absent for full-and-permanent sessions
|
||||
// (every old host): today's overlay must not change there.
|
||||
if model.accessLimited {
|
||||
Text(model.accessRemainingSecs == 0
|
||||
? "access \(model.accessLevel.label.lowercased())"
|
||||
: "access \(model.accessLevel.label.lowercased()) · ends in "
|
||||
+ SessionModel.accessCountdown(model.accessRemainingSecs))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
if model.endToEndValid {
|
||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||
@@ -210,15 +225,20 @@ struct StreamHUDView: View {
|
||||
// Capture hint, shown only until input is captured — how to grab it. The RELEASE
|
||||
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
|
||||
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
|
||||
// Both hints are additionally gated on the session's grants ALLOWING a capture
|
||||
// (per-client access §7): inviting a Controller-only or View-only session to
|
||||
// "capture input" the host would only drop is the lie the grants advert exists
|
||||
// to prevent. Read live off the connection — a re-render lands with the model's
|
||||
// access churn.
|
||||
#if os(macOS)
|
||||
if !model.mouseCaptured {
|
||||
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
|
||||
Text("Click the stream to capture input")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) captures kb/mouse.
|
||||
if !model.mouseCaptured {
|
||||
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
|
||||
Text("⌘⎋ captures keyboard & mouse")
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -361,6 +381,68 @@ struct MotionUnreachableBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The session's access chip (per-client access §7) — "Controller only · ends in 1 h 58 m".
|
||||
/// Rides over the stream for the life of a LIMITED session, at every stats tier and with the
|
||||
/// overlay off entirely, in the badges' glass language: what this session may do (and for how
|
||||
/// long) is not a statistic, and a guest whose keyboard does nothing deserves the why on
|
||||
/// screen. Never mounted for full-and-permanent sessions — today's look does not change.
|
||||
/// (tvOS states the same fact as a stats-overlay line instead — a chip would fight the couch
|
||||
/// UI's single-focus rule.)
|
||||
struct AccessChipBadge: View {
|
||||
let label: String
|
||||
/// Seconds until access expires; `0` = permanent (the chip then shows the level alone).
|
||||
let remainingSecs: UInt32
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
Text(remainingSecs == 0
|
||||
? label
|
||||
: "\(label) · ends in \(SessionModel.accessCountdown(remainingSecs))")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(
|
||||
remainingSecs == 0
|
||||
? "Access level: \(label)"
|
||||
: "Access level: \(label), ends in \(SessionModel.accessCountdown(remainingSecs))")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The expiry-warning toast (per-client access §7): the host's T−5 m / T−1 m `AccessUpdate`
|
||||
/// warnings, surfaced briefly in the badge stack — every platform, tvOS included (unlike the
|
||||
/// chip, a warning is worth a moment of couch overlay; it is how "the pad just died" becomes
|
||||
/// "the evening's access ended, ask for more").
|
||||
struct AccessWarningBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "clock.badge.exclamationmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
Text(text)
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(text)
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
|
||||
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
|
||||
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with — see the note in `PairSheet`.
|
||||
@State private var clientName = DeviceName.current
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
|
||||
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
|
||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||
return try PunktfunkKit.pair(
|
||||
host: address, port: port, identity: identity,
|
||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||
pin: pin, name: name.isEmpty ? DeviceName.current : name)
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !token.cancelled else { return } // screen dismissed mid-ceremony
|
||||
|
||||
@@ -21,11 +21,9 @@ struct PairSheet: View {
|
||||
let onPaired: (Data) -> Void
|
||||
|
||||
@State private var pin = ""
|
||||
#if os(macOS)
|
||||
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
|
||||
// approves from the console's pending list and one that pairs by PIN land under one name.
|
||||
@State private var clientName = DeviceName.current
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The name this device tells a host it is — the label an operator approves in the web console.
|
||||
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro".
|
||||
///
|
||||
/// The host shows it in its pending-approval list — the web console's outstanding-pairings view and
|
||||
/// the dialog that approves a knock — and files the device under it in the trust store. It is the
|
||||
/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS
|
||||
/// name the user set, not from a placeholder.
|
||||
///
|
||||
/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME`
|
||||
/// — a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so
|
||||
/// every Apple client used to fall through to the literal "This device" and a console with an
|
||||
/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to
|
||||
/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default).
|
||||
public enum DeviceName {
|
||||
/// This device's user-facing name, never empty.
|
||||
public static var current: String {
|
||||
#if os(macOS)
|
||||
let name = (Host.current().localizedName ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? (hostName ?? kind) : name
|
||||
#else
|
||||
let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the
|
||||
// user-assigned-device-name entitlement — which turns a household's three iPads into
|
||||
// three identical rows in the host's approval list. The hostname is not behind that
|
||||
// gate on every OS version, and when the user has named the device it carries that
|
||||
// name ("Enricos-iPad"), so prefer it whenever `name` came back generic.
|
||||
if name.isEmpty || name == kind {
|
||||
if let host = hostName { return host }
|
||||
}
|
||||
return name.isEmpty ? kind : name
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The OS hostname without its mDNS `.local` suffix — nil when it is unset or the placeholder
|
||||
/// every unconfigured device reports, which would name nothing.
|
||||
private static var hostName: String? {
|
||||
let host = ProcessInfo.processInfo.hostName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host
|
||||
guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return bare
|
||||
}
|
||||
|
||||
/// What to call the device when the OS has no name for it — the product, which at least tells
|
||||
/// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer
|
||||
/// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name
|
||||
/// entitlement, so the two agree more often than not.)
|
||||
public static var kind: String {
|
||||
#if os(macOS)
|
||||
return "Mac"
|
||||
#elseif os(tvOS)
|
||||
return "Apple TV"
|
||||
#else
|
||||
return UIDevice.current.model // "iPad" / "iPhone"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,12 @@ enum HTTPResponseParser {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
let end = head.bodyStart + length
|
||||
// A malicious host can send Content-Length = Int.max; `bodyStart + length` would then
|
||||
// overflow, and Swift integer overflow TRAPS (uncatchable crash), not throws. Add
|
||||
// reporting overflow and reject instead. security-review 2026-08-15 (low: HTTPResponse
|
||||
// Int overflow).
|
||||
let (end, overflow) = head.bodyStart.addingReportingOverflow(length)
|
||||
if overflow { throw HTTPParseError.malformedHeader }
|
||||
return b.count >= end ? end : nil
|
||||
}
|
||||
return nil // framed by connection close
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,12 @@ public enum HostRejection: Sendable {
|
||||
case superseded
|
||||
case wireVersionMismatch
|
||||
case busy
|
||||
/// This device's access grant expired (per-client access §4) — at connect (an expired
|
||||
/// record races the knock path), or as the typed close ending a live session.
|
||||
case accessExpired
|
||||
/// The Hello asked to launch a title but this device's grants exclude `LAUNCH` — refused
|
||||
/// at the handshake so the user gets a sentence, not a bare desktop they didn't ask for.
|
||||
case launchNotPermitted
|
||||
|
||||
init?(status: Int32) {
|
||||
switch status {
|
||||
@@ -111,6 +117,8 @@ public enum HostRejection: Sendable {
|
||||
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
|
||||
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
|
||||
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
|
||||
case PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED.rawValue: self = .accessExpired
|
||||
case PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED.rawValue: self = .launchNotPermitted
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -140,6 +148,12 @@ public enum HostRejection: Sendable {
|
||||
return "Client and host versions don't match — update both to the same release."
|
||||
case .busy:
|
||||
return "The host is busy with another session."
|
||||
case .accessExpired:
|
||||
return "Your access to this host has expired — ask its owner to grant "
|
||||
+ "access again."
|
||||
case .launchNotPermitted:
|
||||
return "This device isn't permitted to launch games on the host — connect "
|
||||
+ "to the desktop instead, or ask the owner to allow launching."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +495,135 @@ public final class PunktfunkConnection {
|
||||
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
|
||||
}
|
||||
|
||||
// MARK: - Per-client access (design/per-client-access.md §7)
|
||||
|
||||
/// The `PUNKTFUNK_GRANT_*` access bits — what a paired device may DO on the host, per the
|
||||
/// session's live grants (``accessGrants``). Values are wire/ABI-frozen (the header's
|
||||
/// expression macros don't import into Swift, like `userFlagChunkAligned`'s).
|
||||
public static let grantGamepad: UInt32 = 1 << 0
|
||||
public static let grantPointer: UInt32 = 1 << 1
|
||||
public static let grantKeyboard: UInt32 = 1 << 2
|
||||
public static let grantClipboard: UInt32 = 1 << 3
|
||||
public static let grantMic: UInt32 = 1 << 4
|
||||
public static let grantLaunch: UInt32 = 1 << 5
|
||||
/// Every defined grant — full control, today's behavior and what an old host's Welcome
|
||||
/// decodes to.
|
||||
public static let grantAll: UInt32 = 0x3F
|
||||
|
||||
/// The three user-facing access presets plus "Custom", DERIVED from the mask (never
|
||||
/// stored — design §3.2, no drift). The label vocabulary is the cross-client one the web
|
||||
/// console's Access column uses.
|
||||
public enum AccessLevel: Sendable, Equatable {
|
||||
case fullControl
|
||||
case controllerOnly
|
||||
case viewOnly
|
||||
case custom
|
||||
|
||||
public init(grants: UInt32) {
|
||||
switch grants & PunktfunkConnection.grantAll {
|
||||
case PunktfunkConnection.grantAll: self = .fullControl
|
||||
case PunktfunkConnection.grantGamepad: self = .controllerOnly
|
||||
case 0: self = .viewOnly
|
||||
default: self = .custom
|
||||
}
|
||||
}
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .fullControl: return "Full control"
|
||||
case .controllerOnly: return "Controller only"
|
||||
case .viewOnly: return "View only"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's LIVE effective access grants (`PUNKTFUNK_GRANT_*`): the Welcome advert
|
||||
/// first, then latest-wins over every mid-session `AccessUpdate` (a console edit) — so a
|
||||
/// 1 Hz poll of this is how the chip and the capture gates track changes. Full control
|
||||
/// against an old host, and after close (nothing to restrict on a dead session).
|
||||
///
|
||||
/// Courtesy truth only: the HOST enforces the mask regardless. The client uses it to not
|
||||
/// capture what can't land — a keyboard that silently does nothing is the failure mode
|
||||
/// this exists to prevent.
|
||||
public var accessGrants: UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return Self.grantAll }
|
||||
var grants: UInt32 = Self.grantAll
|
||||
_ = punktfunk_connection_grants(h, &grants)
|
||||
return grants
|
||||
}
|
||||
|
||||
/// Seconds until this session's access expires, LIVE (the core counts it down from the
|
||||
/// Welcome / the latest `AccessUpdate`, anchored to this device's clock — skew never moves
|
||||
/// it). `0` = permanent — show no countdown then; while a deadline exists it clamps to
|
||||
/// ≥ 1, so `0` stays unambiguous. Poll ~1 Hz for the "ends in 1 h 58 m" chip.
|
||||
public var accessExpiresInSeconds: UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var secs: UInt32 = 0
|
||||
_ = punktfunk_connection_access_expires_in(h, &secs)
|
||||
return secs
|
||||
}
|
||||
|
||||
/// The session's grants allow controller input (pads, rich DualSense input).
|
||||
public var canSendGamepad: Bool { accessGrants & Self.grantGamepad != 0 }
|
||||
/// The session's grants allow pointing input (mouse, scroll, touch, pen) — the
|
||||
/// pointer-lock / touch-capture gate.
|
||||
public var canSendPointer: Bool { accessGrants & Self.grantPointer != 0 }
|
||||
/// The session's grants allow key input — the keyboard-grab gate.
|
||||
public var canSendKeyboard: Bool { accessGrants & Self.grantKeyboard != 0 }
|
||||
/// The session's grants allow the shared clipboard (AND this with
|
||||
/// ``hostSupportsClipboard`` before offering the toggle).
|
||||
public var canUseClipboard: Bool { accessGrants & Self.grantClipboard != 0 }
|
||||
/// The session's grants allow mic injection — hide the mic UI without it.
|
||||
public var canUseMic: Bool { accessGrants & Self.grantMic != 0 }
|
||||
/// Anything about this session's access differs from the everyday full-and-permanent —
|
||||
/// the chip's visibility gate: full + permanent must look exactly like today.
|
||||
public var accessIsLimited: Bool {
|
||||
accessGrants & Self.grantAll != Self.grantAll || accessExpiresInSeconds != 0
|
||||
}
|
||||
|
||||
/// The grant bit one wire input kind needs — the Swift mirror of core's exhaustive
|
||||
/// `classify` (keys → keyboard; mouse/scroll/touch → pointer; pads → gamepad), consulted
|
||||
/// by ``send(_:)``'s courtesy filter. An unknown/future kind maps to 0 — never granted —
|
||||
/// matching the host's default-deny.
|
||||
private static func grantBit(forInputKind kind: UInt8) -> UInt32 {
|
||||
switch UInt32(kind) {
|
||||
case PUNKTFUNK_INPUT_KIND_KEY_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_KEY_UP.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TEXT_INPUT.rawValue:
|
||||
return grantKeyboard
|
||||
case PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue:
|
||||
return grantPointer
|
||||
case PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue,
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue:
|
||||
return grantGamepad
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the LIVE grants include `bit`. Call with `abiLock` held and a live handle —
|
||||
/// the send paths' shape, so the read and the send see the same session.
|
||||
private func granted(_ bit: UInt32, handle h: OpaquePointer) -> Bool {
|
||||
var grants: UInt32 = Self.grantAll
|
||||
_ = punktfunk_connection_grants(h, &grants)
|
||||
return grants & bit != 0
|
||||
}
|
||||
|
||||
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
|
||||
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial` —
|
||||
/// states reference shapes by it and a re-shown serial never resends pixels.
|
||||
@@ -604,6 +747,7 @@ public final class PunktfunkConnection {
|
||||
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
|
||||
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
|
||||
launchID: String? = nil,
|
||||
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
@@ -616,25 +760,33 @@ public final class PunktfunkConnection {
|
||||
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
|
||||
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
|
||||
// the session; the host resolves it against its own library — nil = the host's default.
|
||||
// `label` is what an unpaired knock shows up as in the host's approval list (and the web
|
||||
// console's outstanding-pairings view): this device's OS name unless the caller overrode
|
||||
// it. Without it the core falls back to environment variables no Apple app has, and every
|
||||
// device pending approval reads "This device".
|
||||
let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let label = override.isEmpty ? DeviceName.current : override
|
||||
handle = host.withCString { cs in
|
||||
withOptionalCString(identity?.certPEM) { cert in
|
||||
withOptionalCString(identity?.keyPEM) { key in
|
||||
withOptionalCString(launchID) { launch in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, timeoutMs, &connectStatus)
|
||||
label.withCString { name in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||
cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect_ex10(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, name, timeoutMs, &connectStatus)
|
||||
}
|
||||
return punktfunk_connect_ex9(
|
||||
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
|
||||
videoCodecs, preferredCodec, clientCaps, launch,
|
||||
nil, &observed, cert, key, timeoutMs, &connectStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1271,12 +1423,17 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
/// silently dropped after close — and dropped when the session's live grants exclude the
|
||||
/// event's class (the courtesy mirror of the host's classify-and-drop: the HOST enforces
|
||||
/// regardless, but not putting undeliverable events on the wire is what lets every input
|
||||
/// path honor a mid-session grant edit without each caller re-checking).
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
var ev = event
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
guard let h = handle, !closeRequested,
|
||||
granted(Self.grantBit(forInputKind: ev.kind), handle: h)
|
||||
else { return }
|
||||
_ = punktfunk_connection_send_input(h, &ev)
|
||||
}
|
||||
|
||||
@@ -1287,7 +1444,9 @@ public final class PunktfunkConnection {
|
||||
guard !samples.isEmpty else { return }
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// The pen plane is pointing input — same courtesy grant gate as `send(_:)`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantPointer, handle: h)
|
||||
else { return }
|
||||
samples.withUnsafeBufferPointer { buf in
|
||||
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
|
||||
}
|
||||
@@ -1339,7 +1498,10 @@ public final class PunktfunkConnection {
|
||||
public func sendMic(_ opus: Data, seq: UInt32, ptsNs: UInt64) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Mic injection needs its grant — same courtesy gate as `send(_:)` (the host drops
|
||||
// the plane regardless; the UI additionally hides the mic controls via `canUseMic`).
|
||||
guard let h = handle, !closeRequested, granted(Self.grantMic, handle: h)
|
||||
else { return }
|
||||
opus.withUnsafeBytes { p in
|
||||
_ = punktfunk_connection_send_mic(
|
||||
h, p.bindMemory(to: UInt8.self).baseAddress, UInt(opus.count), seq, ptsNs)
|
||||
@@ -1353,7 +1515,9 @@ public final class PunktfunkConnection {
|
||||
public func sendTouchpad(pad: UInt8 = 0, finger: UInt8, active: Bool, x: UInt16, y: UInt16) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Rich pad input rides the GAMEPAD grant (it IS controller input) — same gate as `send`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
|
||||
else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_TOUCHPAD)
|
||||
rich.pad = pad
|
||||
@@ -1374,7 +1538,9 @@ public final class PunktfunkConnection {
|
||||
) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
// Motion is controller input too — same GAMEPAD gate as `sendTouchpad`.
|
||||
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
|
||||
else { return }
|
||||
var rich = PunktfunkRichInput()
|
||||
rich.kind = UInt8(PUNKTFUNK_RICH_MOTION)
|
||||
rich.pad = pad
|
||||
@@ -1583,6 +1749,20 @@ public final class PunktfunkConnection {
|
||||
/// Shorthand for the single most actionable reason: the host's launched game exited.
|
||||
public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited }
|
||||
|
||||
/// The typed rejection a MID-SESSION close carried, if any — an access expiry being the
|
||||
/// case this exists for: `sessionEndReason` can only file that deliberate close under
|
||||
/// `.hostError`, and "ended with an error" is the wrong sentence for "your access
|
||||
/// expired". Same read discipline as `sessionEndReason` (ask after the end, before
|
||||
/// teardown); nil for every ordinary end and for connect-time rejections (those surface
|
||||
/// from the connect itself as `.rejected`).
|
||||
public var endRejection: HostRejection? {
|
||||
guard let h = liveHandle() else { return nil }
|
||||
var status: Int32 = 0
|
||||
guard punktfunk_connection_end_reject(h, &status) == statusOK, status != 0
|
||||
else { return nil }
|
||||
return HostRejection(status: status)
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
|
||||
@@ -472,15 +472,25 @@ public final class StreamLayerView: NSView {
|
||||
// NSApp.isActive / isKeyWindow are still false for the click coming in from
|
||||
// another app) — only the auto-engage paths require already-held key status.
|
||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||
guard captureEnabled, !captured, connection != nil, window != nil,
|
||||
guard captureEnabled, !captured, let connection, window != nil,
|
||||
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
|
||||
else { return }
|
||||
// Per-client access §7 — never capture what can't land: a Controller-only or
|
||||
// View-only session gets NO mouse/keyboard grab (its clicks stay local UI clicks),
|
||||
// instead of a frozen cursor over input the host silently drops. Live grants, so a
|
||||
// mid-session re-grant makes the next click work; the revoke direction is released
|
||||
// by the session model's access tick.
|
||||
guard connection.canSendPointer || connection.canSendKeyboard else { return }
|
||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
||||
// always engages and the monitor forwards absolute positions instead.
|
||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
||||
// always engages and the monitor forwards absolute positions instead. A session
|
||||
// whose grants exclude POINTER also keeps its cursor free (keyboard-only capture):
|
||||
// freezing a pointer whose motion cannot land would just trap the user's mouse.
|
||||
guard cursorCapture.capture(
|
||||
in: self, disassociate: !desktopMouse && connection.canSendPointer)
|
||||
else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||
|
||||
@@ -316,7 +316,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// full-screen + frontmost and may drop the lock (Slide Over/Stage Manager/backgrounding) —
|
||||
/// syncPointerLock() handles the actual grant/drop and falls back to absolute when unlocked.
|
||||
private var wantsPointerLock: Bool {
|
||||
// The trailing grant test is per-client access §7 — no pointer lock without the
|
||||
// POINTER bit (a Controller-only guest's trackpad stays a normal local pointer);
|
||||
// read live, so a mid-session re-grant lets the next resolve pass lock.
|
||||
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
|
||||
&& connection?.canSendPointer == true
|
||||
}
|
||||
|
||||
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
|
||||
|
||||
@@ -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)?]
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The session's effective access, client-side (design/per-client-access.md §7): one
|
||||
//! snapshot type over the shared grant vocabulary, the preset label derived from the mask
|
||||
//! (never stored — §3.2), the overlay chip's text, and the toast wording for a mid-session
|
||||
//! [`AccessUpdate`](punktfunk_core::quic::AccessUpdate). Pure presentation logic on purpose —
|
||||
//! the HOST enforces the mask whatever a client renders; everything here is the courtesy
|
||||
//! that makes a limited session say what it is instead of feeling broken.
|
||||
//!
|
||||
//! The Apple/Android clients mirror these rules rather than link them — the labels, the
|
||||
//! chip/notice wording and the derive-not-store rule below are the contract they copy.
|
||||
|
||||
use punktfunk_core::quic::{GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What this session may do and for how long — the client-side snapshot of the host's
|
||||
/// [`Welcome`](punktfunk_core::quic::Welcome) advert, revised by every mid-session
|
||||
/// [`AccessUpdate`](punktfunk_core::quic::AccessUpdate) (latest wins). Carried on
|
||||
/// [`SessionEvent::Access`](crate::session::SessionEvent::Access); the default — full
|
||||
/// control, permanent — is exactly what an old host's Welcome decodes to, so a session
|
||||
/// against one renders today's chrome unchanged (no chip, everything enabled).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SessionAccess {
|
||||
/// The effective grant bitmask ([`punktfunk_core::quic::GRANT_GAMEPAD`] family).
|
||||
pub grants: u32,
|
||||
/// When this access ends, on the CLIENT's monotonic clock; `None` = permanent.
|
||||
/// Monotonic so the chip's countdown never jumps with a wall-clock step.
|
||||
pub deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for SessionAccess {
|
||||
fn default() -> Self {
|
||||
SessionAccess {
|
||||
grants: GRANT_ALL,
|
||||
deadline: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionAccess {
|
||||
/// Snapshot the connector's live access truth (grants + deadline), converting the
|
||||
/// wall-clock deadline the core keeps into this process's monotonic clock.
|
||||
pub fn from_connector(c: &punktfunk_core::client::NativeClient) -> SessionAccess {
|
||||
let deadline = c.access_deadline_unix().map(|deadline_unix| {
|
||||
let now_unix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
Instant::now() + Duration::from_secs(deadline_unix.saturating_sub(now_unix))
|
||||
});
|
||||
SessionAccess {
|
||||
grants: c.access_grants(),
|
||||
deadline,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether traffic needing `bit` (one `GRANT_*` constant) may land on the host.
|
||||
pub fn allows(&self, bit: u32) -> bool {
|
||||
self.grants & bit != 0
|
||||
}
|
||||
|
||||
/// Full control, permanent — today's default look, which must stay unchanged: no chip,
|
||||
/// no gating, no toasts (design §7; old-host degrade).
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.grants == GRANT_ALL && self.deadline.is_none()
|
||||
}
|
||||
|
||||
/// Time left before this access expires — `None` = permanent, zero = already due
|
||||
/// (the host's expiry close is on its way).
|
||||
pub fn remaining(&self, now: Instant) -> Option<Duration> {
|
||||
self.deadline.map(|d| d.saturating_duration_since(now))
|
||||
}
|
||||
|
||||
/// The overlay chip's text — "Controller only · ends in 1 h 58 m" — or `None` for the
|
||||
/// default session, which shows no chip at all.
|
||||
pub fn chip_text(&self, now: Instant) -> Option<String> {
|
||||
if self.is_default() {
|
||||
return None;
|
||||
}
|
||||
let label = preset_label(self.grants);
|
||||
match self.remaining(now) {
|
||||
Some(left) => Some(format!("{label} · ends in {}", format_remaining(left))),
|
||||
None => Some(label.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-facing preset name DERIVED from the mask (design §3.2 — never stored, no
|
||||
/// drift): the three presets, and "Custom" for any other combination.
|
||||
pub fn preset_label(grants: u32) -> &'static str {
|
||||
match grants {
|
||||
GRANT_ALL => "Full control",
|
||||
GRANT_PRESET_CONTROLLER_ONLY => "Controller only",
|
||||
GRANT_PRESET_VIEW_ONLY => "View only",
|
||||
_ => "Custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// A remaining-time figure the chip/toast can wear: "1 h 58 m", "2 h", "58 m", and
|
||||
/// "under 1 m" below the resolution the wire's whole seconds can honestly promise.
|
||||
pub fn format_remaining(left: Duration) -> String {
|
||||
let mins = left.as_secs() / 60;
|
||||
match (mins / 60, mins % 60) {
|
||||
(0, 0) => "under 1 m".to_string(),
|
||||
(0, m) => format!("{m} m"),
|
||||
(h, 0) => format!("{h} h"),
|
||||
(h, m) => format!("{h} h {m} m"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The toast for a mid-session access change (design §7 "end honestly"): a grants edit
|
||||
/// names the new level; an unchanged-grants update is the host's expiry warning (T−5 m /
|
||||
/// T−1 m) and names the time left. `None` = nothing worth interrupting for (an update
|
||||
/// that reaffirmed a permanent, unchanged mask).
|
||||
pub fn update_notice(prev_grants: u32, next: &SessionAccess, now: Instant) -> Option<String> {
|
||||
if next.grants != prev_grants {
|
||||
return Some(format!("Access is now {}", preset_label(next.grants)));
|
||||
}
|
||||
match next.remaining(now) {
|
||||
Some(left) if left > Duration::ZERO => {
|
||||
Some(format!("Access ends in {}", format_remaining(left)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{GRANT_CLIPBOARD, GRANT_GAMEPAD, GRANT_KEYBOARD, GRANT_POINTER};
|
||||
|
||||
#[test]
|
||||
fn labels_derive_from_the_mask_per_the_design() {
|
||||
assert_eq!(preset_label(GRANT_ALL), "Full control");
|
||||
assert_eq!(preset_label(GRANT_GAMEPAD), "Controller only");
|
||||
assert_eq!(preset_label(0), "View only");
|
||||
// Anything off the three presets is Custom — including "controller + clipboard",
|
||||
// the media-remote example, and a full mask missing one bit.
|
||||
assert_eq!(preset_label(GRANT_GAMEPAD | GRANT_CLIPBOARD), "Custom");
|
||||
assert_eq!(preset_label(GRANT_ALL & !GRANT_KEYBOARD), "Custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_session_wears_no_chip() {
|
||||
let now = Instant::now();
|
||||
assert!(SessionAccess::default().is_default());
|
||||
assert_eq!(SessionAccess::default().chip_text(now), None);
|
||||
// …and each departure from the default brings one: a narrower mask, or a deadline.
|
||||
let limited = SessionAccess {
|
||||
grants: GRANT_GAMEPAD,
|
||||
deadline: None,
|
||||
};
|
||||
assert_eq!(limited.chip_text(now).as_deref(), Some("Controller only"));
|
||||
let expiring = SessionAccess {
|
||||
grants: GRANT_ALL,
|
||||
deadline: Some(now + Duration::from_secs(2 * 3600 - 120)),
|
||||
};
|
||||
assert_eq!(
|
||||
expiring.chip_text(now).as_deref(),
|
||||
Some("Full control · ends in 1 h 58 m")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_time_formats_at_honest_granularity() {
|
||||
assert_eq!(format_remaining(Duration::from_secs(0)), "under 1 m");
|
||||
assert_eq!(format_remaining(Duration::from_secs(59)), "under 1 m");
|
||||
assert_eq!(format_remaining(Duration::from_secs(60)), "1 m");
|
||||
assert_eq!(format_remaining(Duration::from_secs(58 * 60)), "58 m");
|
||||
assert_eq!(format_remaining(Duration::from_secs(2 * 3600)), "2 h");
|
||||
assert_eq!(
|
||||
format_remaining(Duration::from_secs(3600 + 58 * 60 + 30)),
|
||||
"1 h 58 m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_name_a_grants_change_first_and_warnings_by_time_left() {
|
||||
let now = Instant::now();
|
||||
// A console edit: the new level is the news, even with a deadline running.
|
||||
let narrowed = SessionAccess {
|
||||
grants: GRANT_GAMEPAD,
|
||||
deadline: Some(now + Duration::from_secs(300)),
|
||||
};
|
||||
assert_eq!(
|
||||
update_notice(GRANT_ALL, &narrowed, now).as_deref(),
|
||||
Some("Access is now Controller only")
|
||||
);
|
||||
// The host's T−5 m warning: same grants, a deadline — name the time.
|
||||
let warned = SessionAccess {
|
||||
grants: GRANT_GAMEPAD,
|
||||
deadline: Some(now + Duration::from_secs(300)),
|
||||
};
|
||||
assert_eq!(
|
||||
update_notice(GRANT_GAMEPAD, &warned, now).as_deref(),
|
||||
Some("Access ends in 5 m")
|
||||
);
|
||||
// An update that reaffirmed a permanent, unchanged mask: nothing to say.
|
||||
let same = SessionAccess {
|
||||
grants: GRANT_POINTER,
|
||||
deadline: None,
|
||||
};
|
||||
assert_eq!(update_notice(GRANT_POINTER, &same, now), None);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ pub mod deeplink;
|
||||
// state machine every front-end drives, and the session spawn + stdout contract.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod orchestrate;
|
||||
// The session's effective access, client-side (design/per-client-access.md §7): the
|
||||
// snapshot type over the shared grant vocabulary, the derived preset label, the overlay
|
||||
// chip's text and the AccessUpdate toast wording. Pure presentation logic — the
|
||||
// Apple/Android ports mirror its rules rather than link it. Gated with the session
|
||||
// modules only because macOS has no punktfunk-core dependency to name the grants with.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod access;
|
||||
// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string
|
||||
// logic, built everywhere (the Apple/Android ports mirror it rather than link it).
|
||||
pub mod os;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,21 @@ pub enum SessionEvent {
|
||||
msg: String,
|
||||
},
|
||||
Stats(Stats),
|
||||
/// The session's effective access (design/per-client-access.md §7): emitted once right
|
||||
/// after [`Self::Connected`] with the Welcome's advert, then again for every mid-session
|
||||
/// `AccessUpdate` the host sends (a console edit, the T−5 m / T−1 m expiry warnings) —
|
||||
/// latest wins. `notice` is the toast-worthy one-liner for a mid-session change
|
||||
/// ("Access is now Controller only", "Access ends in 5 m"); `None` on the initial
|
||||
/// snapshot and on updates with nothing worth interrupting for.
|
||||
///
|
||||
/// Courtesy chrome only — the HOST enforces the mask whatever an embedder does with
|
||||
/// this. Embedders use it to gate capture (no pointer lock / keyboard grab without the
|
||||
/// bits) and to wear the overlay chip; a default access (full control, permanent — every
|
||||
/// old host) must render exactly today's look.
|
||||
Access {
|
||||
access: crate::access::SessionAccess,
|
||||
notice: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// How many times THIS PROCESS has had a session's codec exhaust the decode ladder — the
|
||||
@@ -606,6 +621,14 @@ fn pump(
|
||||
mode: connector.mode(),
|
||||
fingerprint: connector.host_fingerprint,
|
||||
});
|
||||
// The Welcome's access advert, straight after Connected so the embedder can gate its
|
||||
// capture BEFORE it engages (design §7 "not capture what can't land"). Old hosts decode
|
||||
// to full-control/permanent and the embedder renders today's look unchanged.
|
||||
let mut access = crate::access::SessionAccess::from_connector(&connector);
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Access {
|
||||
access,
|
||||
notice: None,
|
||||
});
|
||||
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/native-*/software).
|
||||
@@ -728,30 +751,35 @@ fn pump(
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
let clipboard_thread = params
|
||||
.clipboard
|
||||
.then(|| {
|
||||
let c = connector.clone();
|
||||
let s = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-clipboard".into())
|
||||
.spawn(move || crate::clipboard::run(c, s))
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
// away when the host has no clipboard capability, so spawning is gated only by the
|
||||
// setting — and by the session's CLIPBOARD grant (the client half of design §5.4
|
||||
// "deny at setup": the host's coordinator never starts for an ungranted session, so a
|
||||
// bridge here would only ever collect NOT_PERMITTED refusals).
|
||||
let clipboard_thread = (params.clipboard
|
||||
&& access.allows(punktfunk_core::quic::GRANT_CLIPBOARD))
|
||||
.then(|| {
|
||||
let c = connector.clone();
|
||||
let s = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-clipboard".into())
|
||||
.spawn(move || crate::clipboard::run(c, s))
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
// The uplink, and with it the mute the embedder's chord drives. `set_live` is what makes
|
||||
// the chord (and its indicator) real: a mic turned off in Settings, or a capture device
|
||||
// that wouldn't open, leaves it false and the chord stays an honest no-op.
|
||||
let _mic = params
|
||||
.mic_enabled
|
||||
// the chord (and its indicator) real: a mic turned off in Settings, a capture device
|
||||
// that wouldn't open, OR a session without the MIC grant (the host would drop the
|
||||
// datagrams — don't open the capture device for a plane that can't land) leaves it
|
||||
// false and the chord stays an honest no-op. `mut`: a mid-session AccessUpdate moves
|
||||
// the grant, and the uplink follows it live below.
|
||||
let mut mic_uplink = (params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC))
|
||||
.then(|| {
|
||||
audio::MicStreamer::spawn(connector.clone(), mic.flag(), params.echo_cancel)
|
||||
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
mic.set_live(_mic.is_some());
|
||||
mic.set_live(mic_uplink.is_some());
|
||||
|
||||
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
@@ -874,6 +902,39 @@ fn pump(
|
||||
debug_reconfig = None;
|
||||
}
|
||||
}
|
||||
// Mid-session access updates (a console edit, the T−5 m / T−1 m expiry warnings).
|
||||
// Drain the queue and re-read the connector's live truth ONCE — latest wins per
|
||||
// design, and the connector already folded every update before waking us. The mic
|
||||
// uplink follows its grant live: removed → the capture device closes now (the host
|
||||
// is dropping the plane anyway); granted back (and wanted in Settings) → it starts
|
||||
// again without a reconnect.
|
||||
{
|
||||
let mut updated = false;
|
||||
while connector.next_access_update(Duration::ZERO).is_ok() {
|
||||
updated = true;
|
||||
}
|
||||
if updated {
|
||||
let prev = access;
|
||||
access = crate::access::SessionAccess::from_connector(&connector);
|
||||
let notice = crate::access::update_notice(prev.grants, &access, Instant::now());
|
||||
let mic_on = params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC);
|
||||
if !mic_on && mic_uplink.is_some() {
|
||||
tracing::info!("MIC grant removed mid-session — stopping the mic uplink");
|
||||
mic_uplink = None;
|
||||
mic.set_live(false);
|
||||
} else if mic_on && mic_uplink.is_none() {
|
||||
mic_uplink = audio::MicStreamer::spawn(
|
||||
connector.clone(),
|
||||
mic.flag(),
|
||||
params.echo_cancel,
|
||||
)
|
||||
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
|
||||
.ok();
|
||||
mic.set_live(mic_uplink.is_some());
|
||||
}
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Access { access, notice });
|
||||
}
|
||||
}
|
||||
// 20 ms wait: audio has its own thread now, so this only bounds stop-flag
|
||||
// responsiveness and the per-iteration keyframe-recovery check (a frame arrives
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
@@ -1270,6 +1331,14 @@ fn pump(
|
||||
// line in front of the player for quitting their own game.
|
||||
Err(PunktfunkError::Closed) => {
|
||||
use punktfunk_core::client::PunktfunkEndReason as End;
|
||||
// A typed mid-session rejection names itself — today that is the access
|
||||
// expiry (close 0x69, after the host's T−5 m / T−1 m warnings), which
|
||||
// would otherwise file under HostError and render as "the host ended the
|
||||
// session with an error": true, and exactly the wrong sentence. Same
|
||||
// wording as the connect-time path, one vocabulary (design §7).
|
||||
if let Some(reason) = connector.end_reject() {
|
||||
break Some(crate::trust::connect_reject_message(reason));
|
||||
}
|
||||
break match connector.end_reason() {
|
||||
// The player quit the game the host launched. Nothing to report; a launcher
|
||||
// embedder returns to its library, which is where they were headed anyway.
|
||||
|
||||
@@ -867,6 +867,14 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
|
||||
(web console → Log) has the cause."
|
||||
.into()
|
||||
}
|
||||
R::AccessExpired => {
|
||||
"Your access to this host has expired — ask the host's owner to grant it again.".into()
|
||||
}
|
||||
R::LaunchNotPermitted => {
|
||||
"This device isn't permitted to launch games on the host — connect without picking \
|
||||
a game, or ask the host's owner to allow launching."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ struct Drawn {
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
/// The access chip's text ("Controller only · ends in 1 h 58 m"). Its countdown moves
|
||||
/// once a minute, which is exactly one damage redraw a minute — a steady chip costs
|
||||
/// nothing per frame.
|
||||
access: Option<String>,
|
||||
/// The transient access toast (holds the hint pill's slot while up).
|
||||
notice: Option<String>,
|
||||
/// The mic-mute badge is up. Part of the damage key like everything else here — the badge
|
||||
/// is static once drawn, so a muted stream still re-renders nothing per frame.
|
||||
mic_muted: bool,
|
||||
@@ -465,6 +471,8 @@ impl Overlay for SkiaOverlay {
|
||||
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
|
||||
if ctx.stats.is_none()
|
||||
&& ctx.hint.is_none()
|
||||
&& ctx.access.is_none()
|
||||
&& ctx.notice.is_none()
|
||||
&& !ctx.mic_muted
|
||||
&& banner_step == 0
|
||||
&& resize_step == 0
|
||||
@@ -480,6 +488,8 @@ impl Overlay for SkiaOverlay {
|
||||
height: ctx.height,
|
||||
stats: ctx.stats.map(str::to_owned),
|
||||
hint: ctx.hint.map(str::to_owned),
|
||||
access: ctx.access.map(str::to_owned),
|
||||
notice: ctx.notice.map(str::to_owned),
|
||||
mic_muted: ctx.mic_muted,
|
||||
scale_pct: (scale * 100.0).round() as u16,
|
||||
banner_step,
|
||||
@@ -521,7 +531,17 @@ impl Overlay for SkiaOverlay {
|
||||
if want.mic_muted {
|
||||
draw_mic_muted_badge(canvas, font, ctx.width, scale);
|
||||
}
|
||||
if let Some(hint) = &want.hint {
|
||||
// The access chip shares the top-right corner (same tier-independence argument as
|
||||
// the badge — "what may this session do" must survive the stats overlay being
|
||||
// Off), stacking under the badge when both are up.
|
||||
if let Some(access) = &want.access {
|
||||
draw_access_chip(canvas, font, access, ctx.width, want.mic_muted, scale);
|
||||
}
|
||||
// The access toast outranks the capture hint for its few seconds — an "Access
|
||||
// ends in 1 m" must not lose the slot to "click to capture".
|
||||
if let Some(notice) = &want.notice {
|
||||
draw_hint_pill(canvas, font, notice, ctx.width, ctx.height, 1.0, scale);
|
||||
} else if let Some(hint) = &want.hint {
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
|
||||
} else if banner_step > 0 {
|
||||
// The start banner: the leave/stats shortcuts, fading out on its own —
|
||||
@@ -787,6 +807,48 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
|
||||
);
|
||||
}
|
||||
|
||||
/// The access chip (per-client access §7 "say what this session is"): the session's
|
||||
/// derived preset label and its countdown — "Controller only · ends in 1 h 58 m" — on the
|
||||
/// same translucent pill as the rest of the chrome, pinned to the TOP-RIGHT corner and
|
||||
/// stacked under the mic badge when both are up.
|
||||
///
|
||||
/// Standing by design, like the badge and unlike the toasts: "why does my keyboard do
|
||||
/// nothing" and "when does my access end" must be answerable ten minutes in, at every
|
||||
/// stats tier including Off. Never drawn for a full-control permanent session — the run
|
||||
/// loop passes `None` and today's default look stays untouched.
|
||||
fn draw_access_chip(
|
||||
canvas: &Canvas,
|
||||
base_font: &Font,
|
||||
text: &str,
|
||||
width: u32,
|
||||
below_badge: bool,
|
||||
scale: f32,
|
||||
) {
|
||||
// Short line (label + countdown) — fits any window the stream runs in.
|
||||
let font = &chrome_font(base_font, scale);
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent;
|
||||
let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
|
||||
let text_w = font.measure_str(text, None).0;
|
||||
let w = text_w + 2.0 * pad_x;
|
||||
let h = line_h + 2.0 * pad_y;
|
||||
let margin = base::OSD_MARGIN * scale;
|
||||
// One row down when the mic badge holds the corner (its height is the same formula,
|
||||
// sans dot — the dot fits inside the shared line height).
|
||||
let y = margin + if below_badge { h + 8.0 * scale } else { 0.0 };
|
||||
let x = width as f32 - w - margin;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
);
|
||||
canvas.draw_str(
|
||||
text,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
|
||||
);
|
||||
}
|
||||
|
||||
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
|
||||
/// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder
|
||||
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
|
||||
|
||||
@@ -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")))]
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::touch::{Abs, Act, Gestures};
|
||||
use pf_client_core::trust::{MouseMode, TouchMode};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{classify, GRANT_KEYBOARD, GRANT_POINTER};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -70,9 +71,30 @@ pub struct Capture {
|
||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
||||
invert_scroll: bool,
|
||||
gestures: Gestures,
|
||||
/// The session's effective access grants (per-client access §7) — the courtesy gate in
|
||||
/// front of every wire send here, keyed by the SAME `classify()` the host's filter uses:
|
||||
/// an event whose class the mask doesn't cover never leaves this struct (the host would
|
||||
/// drop it anyway; not sending is what keeps "my keyboard does nothing" from being a
|
||||
/// mystery — the run loop pairs this with not grabbing what can't land). Moved live by
|
||||
/// [`Capture::set_grants`] on a mid-session `AccessUpdate`.
|
||||
grants: u32,
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
/// Forward one event IF the session's grants cover its class — the client half of the
|
||||
/// host's classify-and-drop filter, sharing its exhaustive [`classify`] so a future
|
||||
/// `InputKind` can't slip past one side and not the other.
|
||||
fn send(
|
||||
connector: &NativeClient,
|
||||
grants: u32,
|
||||
kind: InputKind,
|
||||
code: u32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
flags: u32,
|
||||
) {
|
||||
if grants & classify(kind).bit() == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = connector.send_input(&InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
@@ -86,12 +108,15 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
impl Capture {
|
||||
/// `abs_ok` = the host injector accepts absolute pointer events; without it the
|
||||
/// desktop model is unavailable and `mouse_mode` silently resolves to capture.
|
||||
/// `grants` = the session's effective access mask (the Welcome advert — the run loop
|
||||
/// keeps it live through [`Capture::set_grants`]).
|
||||
pub fn new(
|
||||
connector: Arc<NativeClient>,
|
||||
touch_mode: TouchMode,
|
||||
invert_scroll: bool,
|
||||
mouse_mode: MouseMode,
|
||||
abs_ok: bool,
|
||||
grants: u32,
|
||||
) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
@@ -108,6 +133,7 @@ impl Capture {
|
||||
touch_mode,
|
||||
invert_scroll,
|
||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||
grants,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +141,73 @@ impl Capture {
|
||||
self.captured
|
||||
}
|
||||
|
||||
/// The session's effective access grants — what the run loop passes to
|
||||
/// `apply_capture` so pointer lock and the keyboard grab track the mask.
|
||||
pub fn grants(&self) -> u32 {
|
||||
self.grants
|
||||
}
|
||||
|
||||
/// Whether engaging capture buys anything at all: with neither POINTER nor KEYBOARD
|
||||
/// granted there is nothing to lock or grab FOR (a view-only or controller-only
|
||||
/// session), so [`Capture::engage`] refuses and the "click to capture" hint stays
|
||||
/// down — the worst failure mode is a locked pointer whose motion lands nowhere.
|
||||
pub fn can_capture(&self) -> bool {
|
||||
self.grants & (GRANT_POINTER | GRANT_KEYBOARD) != 0
|
||||
}
|
||||
|
||||
/// Fold a mid-session `AccessUpdate` into the gate. A class REMOVED while something
|
||||
/// of its kind is held flushes the held state up first, under the OLD mask — the
|
||||
/// host may still honor the ups, and either way nothing stays pressed locally. The
|
||||
/// run loop re-applies pointer lock / keyboard grab (and releases capture entirely
|
||||
/// when [`Capture::can_capture`] went false) right after this.
|
||||
pub fn set_grants(&mut self, grants: u32) {
|
||||
if grants == self.grants {
|
||||
return;
|
||||
}
|
||||
let lost = self.grants & !grants;
|
||||
if lost & GRANT_KEYBOARD != 0 {
|
||||
for vk in self.held_keys.drain() {
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::KeyUp,
|
||||
vk as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
if lost & GRANT_POINTER != 0 {
|
||||
self.pending_rel = (0, 0);
|
||||
self.pending_abs = None;
|
||||
for b in self.held_buttons.drain() {
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonUp,
|
||||
b,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::TouchUp,
|
||||
slot,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.gestures.reset();
|
||||
}
|
||||
self.grants = grants;
|
||||
}
|
||||
|
||||
/// The desktop (absolute, uncaptured) mouse model is active.
|
||||
pub fn desktop(&self) -> bool {
|
||||
self.desktop
|
||||
@@ -153,10 +246,17 @@ impl Capture {
|
||||
!self.captured && !self.user_released
|
||||
}
|
||||
|
||||
/// Engage capture. The caller flips SDL relative mouse mode on (pointer lock).
|
||||
/// Engage capture. The caller flips SDL relative mouse mode on (pointer lock) —
|
||||
/// only on `true`: a session whose grants cover neither pointer nor keyboard
|
||||
/// refuses (see [`Capture::can_capture`]), and the caller must leave the pointer
|
||||
/// free rather than lock it over input that can't land.
|
||||
pub fn engage(&mut self) -> bool {
|
||||
if !self.can_capture() {
|
||||
return false;
|
||||
}
|
||||
self.user_released = false;
|
||||
!std::mem::replace(&mut self.captured, true)
|
||||
self.captured = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Release capture, flushing everything held so nothing sticks down on the host.
|
||||
@@ -172,13 +272,37 @@ impl Capture {
|
||||
self.pending_rel = (0, 0); // never flush motion gathered while captured
|
||||
self.pending_abs = None;
|
||||
for vk in self.held_keys.drain() {
|
||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::KeyUp,
|
||||
vk as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for b in self.held_buttons.drain() {
|
||||
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonUp,
|
||||
b,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::TouchUp,
|
||||
slot,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
// The gesture engine's held left button (a tap-drag in progress) rides in
|
||||
// `held_buttons` above, so it was just flushed — here we only forget its state.
|
||||
@@ -191,11 +315,20 @@ impl Capture {
|
||||
pub fn flush_motion(&mut self) {
|
||||
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
||||
if dx != 0 || dy != 0 {
|
||||
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseMove,
|
||||
0,
|
||||
dx,
|
||||
dy,
|
||||
0,
|
||||
);
|
||||
}
|
||||
if let Some(a) = self.pending_abs.take() {
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseMoveAbs,
|
||||
0,
|
||||
a.x,
|
||||
@@ -231,7 +364,15 @@ impl Capture {
|
||||
// when the key lands (e.g. "press E at the crosshair").
|
||||
self.flush_motion();
|
||||
self.held_keys.insert(vk);
|
||||
send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::KeyDown,
|
||||
vk as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +380,15 @@ impl Capture {
|
||||
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
|
||||
// Flush-on-release may have beaten us to it — only forward if still held.
|
||||
if self.held_keys.remove(&vk) {
|
||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::KeyUp,
|
||||
vk as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +403,15 @@ impl Capture {
|
||||
self.flush_motion();
|
||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||
self.held_buttons.insert(gs);
|
||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonDown,
|
||||
gs,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +419,15 @@ impl Capture {
|
||||
self.flush_motion(); // the release must not beat the motion before it
|
||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||
if self.held_buttons.remove(&gs) {
|
||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonUp,
|
||||
gs,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,12 +447,28 @@ impl Capture {
|
||||
let vy = ay.trunc() as i32;
|
||||
if vy != 0 {
|
||||
ay -= f64::from(vy);
|
||||
send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseScroll,
|
||||
0,
|
||||
vy,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
let vx = ax.trunc() as i32;
|
||||
if vx != 0 {
|
||||
ax -= f64::from(vx);
|
||||
send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseScroll,
|
||||
1,
|
||||
vx,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.scroll_acc = (ax, ay);
|
||||
}
|
||||
@@ -319,6 +500,7 @@ impl Capture {
|
||||
let slot = self.touch_slot(finger_id);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::TouchDown,
|
||||
slot,
|
||||
x,
|
||||
@@ -336,6 +518,7 @@ impl Capture {
|
||||
if let Some(&slot) = self.touch_slots.get(&finger_id) {
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::TouchMove,
|
||||
slot,
|
||||
x,
|
||||
@@ -350,7 +533,15 @@ impl Capture {
|
||||
/// no-ops), but a stray up must never strand a pressed contact on the host.
|
||||
pub fn on_touch_up(&mut self, finger_id: u64) {
|
||||
if let Some(slot) = self.touch_slots.remove(&finger_id) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::TouchUp,
|
||||
slot,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,15 +600,31 @@ impl Capture {
|
||||
if down {
|
||||
self.flush_motion(); // the press lands where the cursor now is
|
||||
self.held_buttons.insert(gs);
|
||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonDown,
|
||||
gs,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
} else if self.held_buttons.remove(&gs) {
|
||||
self.flush_motion();
|
||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
send(
|
||||
&self.connector,
|
||||
self.grants,
|
||||
InputKind::MouseButtonUp,
|
||||
gs,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some((kind, code, x, y, flags)) = other.wire() {
|
||||
send(&self.connector, kind, code, x, y, flags);
|
||||
send(&self.connector, self.grants, kind, code, x, y, flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ pub struct FrameCtx<'a> {
|
||||
pub stats: Option<&'a str>,
|
||||
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
||||
pub hint: Option<&'a str>,
|
||||
/// The access chip (per-client access §7 "say what this session is"): a small standing
|
||||
/// pill — "Controller only · ends in 1 h 58 m" — drawn at every stats tier, `None` for
|
||||
/// a full-control permanent session (today's default look, and every old host).
|
||||
pub access: Option<&'a str>,
|
||||
/// A transient access toast ("Access is now Controller only", "Access ends in 5 m") —
|
||||
/// takes the hint pill's slot with priority while up. The run loop owns its timing.
|
||||
pub notice: Option<&'a str>,
|
||||
/// The user muted their microphone mid-stream (Ctrl+Alt+Shift+V). Draws a persistent
|
||||
/// badge, deliberately independent of the stats tier: a muted mic is a fact about what
|
||||
/// the host is hearing, and "did my mute take?" must be answerable with the overlay off.
|
||||
|
||||
+167
-31
@@ -349,6 +349,16 @@ struct StreamState {
|
||||
/// `None` = nothing sent yet. Edge-detected each iteration from the live mouse model, so
|
||||
/// the chord, the M3 auto-flip, and engage/release all reconcile through one path.
|
||||
sent_client_draws: Option<bool>,
|
||||
/// The session's effective access (per-client access §7): the Welcome's advert, then
|
||||
/// every mid-session `AccessUpdate` (latest wins). Drives the capture gating, the
|
||||
/// overlay chip, and which held state a live edit flushes. The default — full
|
||||
/// control, permanent, what every old host decodes to — renders today's look
|
||||
/// unchanged: no chip, everything enabled.
|
||||
access: pf_client_core::access::SessionAccess,
|
||||
/// A transient access toast ("Access is now Controller only", "Access ends in 5 m")
|
||||
/// and when it went up — cleared after [`ACCESS_NOTICE_S`]. Rides the hint-pill slot
|
||||
/// with priority: an access change outranks "click to capture" for a few seconds.
|
||||
access_notice: Option<(String, Instant)>,
|
||||
/// The params this session was started with, kept so a codec fallback can re-dial
|
||||
/// with `exclude_codecs` widened — see [`SessionEvent::CodecFallback`]. Cloned once
|
||||
/// per session start, so anything the SESSION changed after launch (an accepted mode
|
||||
@@ -398,6 +408,8 @@ impl StreamState {
|
||||
connector: None,
|
||||
capture: None,
|
||||
cursor_chan: None,
|
||||
access: pf_client_core::access::SessionAccess::default(),
|
||||
access_notice: None,
|
||||
last_hint: None,
|
||||
hint_override: false,
|
||||
sent_client_draws: None,
|
||||
@@ -779,7 +791,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
WindowEvent::FocusLost => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(false) {
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
false,
|
||||
false,
|
||||
inhibit_shortcuts,
|
||||
0,
|
||||
);
|
||||
tracing::info!("focus lost — input released");
|
||||
}
|
||||
}
|
||||
@@ -797,14 +816,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// An auto-release (Alt-Tab) undoes itself; a chord release
|
||||
// stays released until the user opts back in.
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.should_reengage() {
|
||||
cap.engage();
|
||||
if cap.should_reengage() && cap.engage() {
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
tracing::info!("focus gained — input recaptured");
|
||||
}
|
||||
@@ -864,15 +883,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.captured() {
|
||||
cap.release(true);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
} else {
|
||||
cap.engage();
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
false,
|
||||
false,
|
||||
inhibit_shortcuts,
|
||||
0,
|
||||
);
|
||||
} else if cap.engage() {
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
}
|
||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||
@@ -894,6 +920,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
true,
|
||||
desktop,
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
}
|
||||
flipped = true;
|
||||
@@ -917,7 +944,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("chord: disconnect");
|
||||
st.request_quit();
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
// The pump emits Ended(None); the end path routes per mode.
|
||||
}
|
||||
continue;
|
||||
@@ -1000,15 +1027,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if !cap.captured() {
|
||||
// The engaging click is suppressed toward the host.
|
||||
cap.engage();
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
);
|
||||
// The engaging click is suppressed toward the host. `engage`
|
||||
// refuses on a session whose access covers neither pointer nor
|
||||
// keyboard — the click then does nothing, which is the honest
|
||||
// rendering of "there is nothing to capture for".
|
||||
if cap.engage() {
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
cap.on_button_down(mouse_btn);
|
||||
}
|
||||
@@ -1182,6 +1214,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
if cap.desktop() {
|
||||
// Reappear where the host last had the pointer, so the
|
||||
@@ -1225,7 +1258,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
while escape_rx.try_recv().is_ok() {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(true) {
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
}
|
||||
}
|
||||
if fullscreen && !opts.fullscreen {
|
||||
@@ -1238,7 +1271,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("controller chord: disconnect");
|
||||
st.request_quit();
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,15 +1412,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
(relative-only input) — using capture"
|
||||
);
|
||||
}
|
||||
// The session's access truth, straight off the Welcome (the pump's
|
||||
// Access event lands in this same drain, but the capture below must
|
||||
// be built gated, not re-gated a beat later).
|
||||
st.access = pf_client_core::access::SessionAccess::from_connector(&c);
|
||||
let mut cap = Capture::new(
|
||||
c.clone(),
|
||||
opts.touch_mode,
|
||||
opts.invert_scroll,
|
||||
opts.mouse_mode,
|
||||
abs_ok,
|
||||
st.access.grants,
|
||||
);
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
|
||||
// Capture engages when the stream starts (ui_stream parity) — unless
|
||||
// this session's access covers neither pointer nor keyboard (view-only
|
||||
// / controller-only), where `engage` refuses and the pointer stays
|
||||
// free over the stream (§7 "not capture what can't land").
|
||||
if cap.engage() {
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
}
|
||||
st.capture = Some(cap);
|
||||
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
|
||||
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
|
||||
@@ -1430,6 +1480,44 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
st.last_stats = Some(s);
|
||||
}
|
||||
// The session's access — the Welcome's advert first, then every
|
||||
// mid-session AccessUpdate (design §7). Re-gate the live capture to the
|
||||
// new mask: a removed POINTER/KEYBOARD bit releases the pointer lock /
|
||||
// keyboard grab it backed, and with neither class left the capture drops
|
||||
// entirely (auto-release, so a later re-grant re-engages on click).
|
||||
// Courtesy chrome — the host enforces the mask regardless.
|
||||
SessionEvent::Access { access, notice } => {
|
||||
st.access = access;
|
||||
if let Some(n) = notice {
|
||||
tracing::info!(notice = %n, "session access changed");
|
||||
st.access_notice = Some((n, Instant::now()));
|
||||
}
|
||||
if let Some(cap) = st.capture.as_mut() {
|
||||
cap.set_grants(access.grants);
|
||||
if cap.captured() {
|
||||
if cap.can_capture() {
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
true,
|
||||
cap.desktop(),
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
} else {
|
||||
cap.release(false);
|
||||
apply_capture(
|
||||
&mut window,
|
||||
&mouse,
|
||||
false,
|
||||
false,
|
||||
inhibit_shortcuts,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
@@ -1446,7 +1534,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
// A user-canceled dial ends silently — no error scene.
|
||||
if canceled {
|
||||
@@ -1463,7 +1551,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = &mut st.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
match &mode {
|
||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||
ModeCtl::Browse(_) => {
|
||||
@@ -1512,7 +1600,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = &mut st.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
|
||||
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
|
||||
// Widen the exclusion rather than replace it: a second fallback in the
|
||||
// same run must not re-offer what the first one already ruled out.
|
||||
let mut params = st.params.clone();
|
||||
@@ -1608,17 +1696,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
st.resize_overlay.tick(Instant::now());
|
||||
}
|
||||
|
||||
// Access toast expiry — before the overlay borrows the stream immutably.
|
||||
if let Some(st) = stream.as_mut() {
|
||||
if st
|
||||
.access_notice
|
||||
.as_ref()
|
||||
.is_some_and(|(_, at)| at.elapsed() >= Duration::from_secs(ACCESS_NOTICE_S))
|
||||
{
|
||||
st.access_notice = None;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let (stats, hint) = match &stream {
|
||||
Some(st) if st.connector.is_some() => {
|
||||
// No "click to capture" over a session with nothing to capture FOR
|
||||
// (view-only / controller-only — the chip says what this session is).
|
||||
let hint = match &st.capture {
|
||||
Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() {
|
||||
HINT_WITH_PAD
|
||||
} else {
|
||||
HINT_KEYBOARD
|
||||
}),
|
||||
Some(cap) if !cap.captured() && cap.can_capture() => {
|
||||
Some(if gamepad.active().is_some() {
|
||||
HINT_WITH_PAD
|
||||
} else {
|
||||
HINT_KEYBOARD
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
@@ -1629,6 +1732,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
// The access chip (design §7 "say what this session is"): a small standing
|
||||
// pill — "Controller only · ends in 1 h 58 m" — in the same overlay family as
|
||||
// the stats HUD, at every stats tier including Off. `None` (and so exactly
|
||||
// today's look) for a full-control permanent session, which is every session
|
||||
// against an old host. The countdown re-derives per pass; the overlay's
|
||||
// damage gate turns its once-a-minute text change into a redraw.
|
||||
let access_chip = match &stream {
|
||||
Some(st) if st.connector.is_some() => st.access.chip_text(Instant::now()),
|
||||
_ => None,
|
||||
};
|
||||
let access_notice = stream
|
||||
.as_ref()
|
||||
.filter(|st| st.connector.is_some())
|
||||
.and_then(|st| st.access_notice.as_ref().map(|(n, _)| n.as_str()));
|
||||
let pad = gamepad.active();
|
||||
let pads = gamepad.pads();
|
||||
let resizing = stream
|
||||
@@ -1647,6 +1764,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
scale: overlay_scale(window.display_scale(), osd_scale_pref),
|
||||
stats,
|
||||
hint,
|
||||
access: access_chip.as_deref(),
|
||||
notice: access_notice,
|
||||
mic_muted,
|
||||
resizing,
|
||||
pad: pad.as_ref().map(|p| p.name.as_str()),
|
||||
@@ -2464,16 +2583,29 @@ impl ResizeIndicator {
|
||||
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
|
||||
/// who draws it) — and system chords stay local (a remote desktop is something you
|
||||
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
|
||||
///
|
||||
/// `grants` is the session's effective access mask (per-client access §7 "not capture
|
||||
/// what can't land"): no pointer lock without the POINTER bit, no keyboard grab without
|
||||
/// KEYBOARD — a locked pointer whose motion the host drops, or grabbed system chords
|
||||
/// over dead keys, is the "my input does nothing and nobody says why" failure mode this
|
||||
/// exists to prevent. On-sites pass `Capture::grants()`; off-sites pass `0` (with `on`
|
||||
/// false every term is off regardless).
|
||||
fn apply_capture(
|
||||
window: &mut sdl3::video::Window,
|
||||
mouse: &sdl3::mouse::MouseUtil,
|
||||
on: bool,
|
||||
desktop: bool,
|
||||
inhibit: bool,
|
||||
grants: u32,
|
||||
) {
|
||||
mouse.set_relative_mouse_mode(window, on && !desktop);
|
||||
mouse.show_cursor(!on);
|
||||
let grab = on && !desktop && inhibit;
|
||||
use punktfunk_core::quic::{GRANT_KEYBOARD, GRANT_POINTER};
|
||||
let pointer = grants & GRANT_POINTER != 0;
|
||||
mouse.set_relative_mouse_mode(window, on && !desktop && pointer);
|
||||
// The local cursor hides only while the HOST's cursor stands in for it — without the
|
||||
// POINTER grant no absolute/relative send lands, so hiding it would leave a
|
||||
// keyboard-only session with no cursor at all.
|
||||
mouse.show_cursor(!(on && pointer));
|
||||
let grab = on && !desktop && inhibit && grants & GRANT_KEYBOARD != 0;
|
||||
if !window.set_keyboard_grab(grab) && grab {
|
||||
// The one refusal SDL reports is a missing mechanism — a Wayland compositor with no
|
||||
// shortcuts-inhibit global. Said once per process: the answer never changes
|
||||
@@ -2763,6 +2895,10 @@ struct PresentedWindow {
|
||||
forced: u32,
|
||||
}
|
||||
|
||||
/// How long an access toast holds the pill slot (an "Access ends in…" warning must be
|
||||
/// seen, not studied — the chip keeps the standing truth).
|
||||
const ACCESS_NOTICE_S: u64 = 6;
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||
Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ include = ["PunktfunkEndReason"]
|
||||
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
|
||||
# namespaced, just not by us.
|
||||
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
|
||||
"ACCESS_EXPIRED_CLOSE_CODE" = "PUNKTFUNK_ACCESS_EXPIRED_CLOSE_CODE"
|
||||
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
|
||||
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
|
||||
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
|
||||
@@ -113,6 +114,7 @@ include = ["PunktfunkEndReason"]
|
||||
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
|
||||
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
|
||||
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
|
||||
"CLIP_REASON_NOT_PERMITTED" = "PUNKTFUNK_CLIP_REASON_NOT_PERMITTED"
|
||||
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
|
||||
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
|
||||
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
|
||||
@@ -137,6 +139,17 @@ include = ["PunktfunkEndReason"]
|
||||
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
|
||||
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
|
||||
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
|
||||
"GRANT_ALL" = "PUNKTFUNK_GRANT_ALL"
|
||||
"GRANT_CLIPBOARD" = "PUNKTFUNK_GRANT_CLIPBOARD"
|
||||
"GRANT_GAMEPAD" = "PUNKTFUNK_GRANT_GAMEPAD"
|
||||
"GRANT_KEYBOARD" = "PUNKTFUNK_GRANT_KEYBOARD"
|
||||
"GRANT_LAUNCH" = "PUNKTFUNK_GRANT_LAUNCH"
|
||||
"GRANT_MIC" = "PUNKTFUNK_GRANT_MIC"
|
||||
"GRANT_POINTER" = "PUNKTFUNK_GRANT_POINTER"
|
||||
"GRANT_PRESET_CONTROLLER_ONLY" = "PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY"
|
||||
"GRANT_PRESET_FULL" = "PUNKTFUNK_GRANT_PRESET_FULL"
|
||||
"GRANT_PRESET_VIEW_ONLY" = "PUNKTFUNK_GRANT_PRESET_VIEW_ONLY"
|
||||
"GRANT_RESERVED" = "PUNKTFUNK_GRANT_RESERVED"
|
||||
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
|
||||
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
|
||||
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
|
||||
@@ -155,6 +168,7 @@ include = ["PunktfunkEndReason"]
|
||||
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
|
||||
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
|
||||
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
|
||||
"LAUNCH_NOT_PERMITTED_CLOSE_CODE" = "PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE"
|
||||
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
|
||||
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
|
||||
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
|
||||
@@ -163,6 +177,7 @@ include = ["PunktfunkEndReason"]
|
||||
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
|
||||
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
|
||||
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
|
||||
"MSG_ACCESS_UPDATE" = "PUNKTFUNK_MSG_ACCESS_UPDATE"
|
||||
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
|
||||
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
|
||||
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
|
||||
|
||||
@@ -1811,6 +1811,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
@@ -1873,6 +1874,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
@@ -1935,6 +1937,79 @@ pub unsafe extern "C" fn punktfunk_connect_ex9(
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
|
||||
/// device knocks with — what the host's **pending-approval** list (and the web console's
|
||||
/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
|
||||
/// trust store files it under once approved. Pass the name the user already recognises this
|
||||
/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
|
||||
/// `Settings.Global.DEVICE_NAME` on Android.
|
||||
///
|
||||
/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
|
||||
/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
|
||||
/// of them knocked as the literal "This device", so a console with three of them pending showed
|
||||
/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
|
||||
/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
|
||||
/// failing a connect over it would be a much worse one.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
|
||||
/// string that stays valid for the duration of the call.
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex10(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
video_codecs: u8,
|
||||
preferred_codec: u8,
|
||||
client_caps: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
device_name: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
|
||||
// applies the same ABI contract to them; this shim dereferences nothing itself.
|
||||
unsafe {
|
||||
connect_ex_impl(
|
||||
host,
|
||||
port,
|
||||
client_caps,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
device_name,
|
||||
timeout_ms,
|
||||
status_out,
|
||||
)
|
||||
@@ -1958,9 +2033,27 @@ pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
|
||||
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
|
||||
|
||||
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
|
||||
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
|
||||
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
|
||||
/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would
|
||||
/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than
|
||||
/// showing a shortened one.
|
||||
#[cfg(feature = "quic")]
|
||||
fn clamp_device_name(s: &str) -> String {
|
||||
let end = s
|
||||
.char_indices()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.take_while(|&i| i <= crate::quic::HELLO_NAME_MAX)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
|
||||
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
|
||||
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
|
||||
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
|
||||
#[cfg(feature = "quic")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn connect_ex_impl(
|
||||
@@ -1982,6 +2075,7 @@ unsafe fn connect_ex_impl(
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
device_name: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
status_out: *mut i32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
@@ -2013,6 +2107,16 @@ unsafe fn connect_ex_impl(
|
||||
Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
// The label the host's pending-approval list shows. Same non-fatal treatment as `launch`:
|
||||
// an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a
|
||||
// connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX`
|
||||
// is a byte cap, and slicing a multi-byte name mid-scalar would panic.
|
||||
// SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
|
||||
// borrowed only for this call.
|
||||
let name = match unsafe { opt_cstr(device_name) } {
|
||||
Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()),
|
||||
_ => crate::client::device_name(),
|
||||
};
|
||||
let mode = crate::config::Mode {
|
||||
width,
|
||||
height,
|
||||
@@ -2069,15 +2173,15 @@ unsafe fn connect_ex_impl(
|
||||
client_caps,
|
||||
// The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no
|
||||
// part/completeness fields, so a part would be indistinguishable from a whole AU.
|
||||
// An `ex10` variant adds the opt-in together with those fields when an ABI embedder
|
||||
// An `ex11` variant adds the opt-in together with those fields when an ABI embedder
|
||||
// (Apple) grows a partial-feed decode path.
|
||||
false,
|
||||
launch,
|
||||
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
|
||||
// embedder gets the OS hostname default — this is what the host's pending-approval
|
||||
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
|
||||
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
|
||||
Some(crate::client::device_name()),
|
||||
// What the host's pending-approval list shows when this embedder knocks unpaired, and
|
||||
// the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when
|
||||
// the embedder supplied one (the name the USER knows the device by — an Apple app has
|
||||
// it and the OS default cannot reach it), else that OS default.
|
||||
Some(name),
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
@@ -3895,6 +3999,133 @@ pub unsafe extern "C" fn punktfunk_connection_host_caps(
|
||||
})
|
||||
}
|
||||
|
||||
/// The session's LIVE effective access grants — a `PUNKTFUNK_GRANT_*` bitmask
|
||||
/// (per-client access, `design/per-client-access.md` §7): seeded from the `Welcome` advert
|
||||
/// and moved by every mid-session `AccessUpdate` the host sends (latest wins), so this is
|
||||
/// current state, NOT a connect-time snapshot. An old host advertises nothing and this reads
|
||||
/// `PUNKTFUNK_GRANT_ALL` — full control, the pre-grants behavior, so an embedder keying UI
|
||||
/// off it changes nothing there.
|
||||
///
|
||||
/// Courtesy truth only: the HOST enforces the mask whatever a client renders. Use it to not
|
||||
/// capture what can't land (no pointer lock / keyboard grab without the bits) and to label
|
||||
/// the session ("Controller only"). Cheap (one relaxed atomic load) — poll it alongside a
|
||||
/// stats tick rather than caching it for the session. Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `grants` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_grants(
|
||||
c: *const PunktfunkConnection,
|
||||
grants: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
|
||||
// it is written; a non-null one is a caller-owned writable slot.
|
||||
unsafe {
|
||||
if !grants.is_null() {
|
||||
*grants = c.inner.access_grants();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Seconds until this session's access expires, LIVE — counted down from the `Welcome`'s
|
||||
/// `expires_in_secs` and re-anchored by every mid-session `AccessUpdate`, so successive reads
|
||||
/// shrink on their own (render a countdown by polling this, ~1 Hz). `0` = permanent: today's
|
||||
/// default, and everything an old host's Welcome decodes to — show nothing then. The deadline
|
||||
/// is anchored to the CLIENT's clock at receipt (the wire carries relative seconds), so
|
||||
/// host/client skew never moves the countdown.
|
||||
///
|
||||
/// While a deadline exists the value never reads `0`: in the sliver between the deadline
|
||||
/// passing and the host's typed expiry close (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`
|
||||
/// via [`punktfunk_connection_end_reject`]) it clamps to `1`, so `0` stays unambiguous.
|
||||
/// The T−5 m / T−1 m warnings are the embedder's to derive from the countdown crossing
|
||||
/// those marks. Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `secs` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_access_expires_in(
|
||||
c: *const PunktfunkConnection,
|
||||
secs: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let remaining = match c.inner.access_deadline_unix() {
|
||||
None => 0,
|
||||
Some(deadline) => {
|
||||
let now = crate::quic::wall_clock_ns() / 1_000_000_000;
|
||||
// Clamp to ≥ 1 while a deadline is set: 0 means "permanent", never "expired".
|
||||
u32::try_from(deadline.saturating_sub(now))
|
||||
.unwrap_or(u32::MAX)
|
||||
.max(1)
|
||||
}
|
||||
};
|
||||
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
|
||||
// it is written; a non-null one is a caller-owned writable slot.
|
||||
unsafe {
|
||||
if !secs.is_null() {
|
||||
*secs = remaining;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// The typed rejection a MID-SESSION close carried, as its `PUNKTFUNK_STATUS_REJECTED_*`
|
||||
/// value (`0` = none — every ordinary end). Exists because
|
||||
/// [`punktfunk_connection_end_reason`] can only file an unrecognized deliberate close under
|
||||
/// `PUNKTFUNK_END_REASON_HOST_ERROR`, and "the host ended the session with an error" is the
|
||||
/// wrong sentence for an access expiry (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`) — the
|
||||
/// case this was added for; any future typed mid-session close surfaces the same way. Ask
|
||||
/// AFTER the session ended, before freeing the handle, exactly like `end_reason` (the two
|
||||
/// latch together); connect-time rejections never land here — they come back from the
|
||||
/// connect call itself.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `status` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_end_reject(
|
||||
c: *const PunktfunkConnection,
|
||||
status: *mut i32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let value = match c.inner.end_reject() {
|
||||
Some(reason) => crate::error::PunktfunkError::Rejected(reason).status() as i32,
|
||||
None => 0,
|
||||
};
|
||||
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
|
||||
// it is written; a non-null one is a caller-owned writable slot.
|
||||
unsafe {
|
||||
if !status.is_null() {
|
||||
*status = value;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
|
||||
/// announced or served until this is called with `enabled = true`. `flags` carries
|
||||
/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
|
||||
@@ -4940,6 +5171,29 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
|
||||
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
|
||||
/// operator naming a device in German or Japanese is not an edge case.
|
||||
#[test]
|
||||
fn device_name_truncates_on_a_character_boundary() {
|
||||
let max = crate::quic::HELLO_NAME_MAX;
|
||||
assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad");
|
||||
|
||||
// Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar.
|
||||
let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4));
|
||||
let cut = clamp_device_name(&straddle);
|
||||
assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len());
|
||||
assert_eq!(
|
||||
cut,
|
||||
"x".repeat(max - 1),
|
||||
"must drop the whole ü, not half of it"
|
||||
);
|
||||
|
||||
// A name whose FIRST character already exceeds the cap has nothing to keep — the
|
||||
// `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator.
|
||||
assert_eq!(clamp_device_name(&"あ".repeat(max)), "あ".repeat(max / 3));
|
||||
}
|
||||
|
||||
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
|
||||
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
|
||||
/// exists on the test's own side either.
|
||||
|
||||
@@ -77,4 +77,13 @@ pub(crate) struct Negotiated {
|
||||
/// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client
|
||||
/// can reach the game library without ever having seen an mDNS advert.
|
||||
pub(crate) mgmt_port: u16,
|
||||
/// The session's effective access grants ([`crate::quic::Welcome::grants`]) — the
|
||||
/// [`crate::quic::GRANT_GAMEPAD`] family. An old host's Welcome decodes to
|
||||
/// [`crate::quic::GRANT_ALL`], the pre-grants behavior. This is only the STARTING truth:
|
||||
/// a mid-session [`crate::quic::AccessUpdate`] moves the live mask the control task keeps
|
||||
/// (see [`crate::NativeClient::access_grants`]).
|
||||
pub(crate) grants: u32,
|
||||
/// Seconds until this device's access expires ([`crate::quic::Welcome::expires_in_secs`]);
|
||||
/// `0` = permanent. Like `grants`, the connect-time seed for the live deadline.
|
||||
pub(crate) expires_in_secs: u32,
|
||||
}
|
||||
|
||||
@@ -116,6 +116,23 @@ pub struct MicUplinkStats {
|
||||
/// the control task is wedged, which callers treat as a closed session.
|
||||
const CTRL_QUEUE: usize = 32;
|
||||
|
||||
/// Inbound access-update queue depth. The traffic is a console edit or an expiry warning —
|
||||
/// a handful per session at most; the live grants/deadline slots hold the truth, so a full
|
||||
/// queue drops news the embedder would re-derive from them anyway.
|
||||
const ACCESS_QUEUE: usize = 8;
|
||||
|
||||
/// The absolute access deadline (client wall clock, unix seconds) a relative
|
||||
/// `expires_in_secs` / `remaining_secs` resolves to at `now_ns`; `0` stays `0` (permanent).
|
||||
/// Anchored to the CLIENT's clock on purpose: the wire value is relative, so host/client
|
||||
/// skew never moves the countdown a chip renders from this.
|
||||
pub(crate) fn access_deadline_from(now_ns: u64, remaining_secs: u32) -> u64 {
|
||||
if remaining_secs == 0 {
|
||||
0
|
||||
} else {
|
||||
now_ns / 1_000_000_000 + u64::from(remaining_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the
|
||||
/// C surface.
|
||||
///
|
||||
@@ -234,6 +251,11 @@ pub struct NativeClient {
|
||||
cursor_shape: Mutex<Receiver<crate::quic::CursorShape>>,
|
||||
/// Inbound per-frame cursor state — `0xD0` datagrams (same negotiation gate as shapes).
|
||||
cursor_state: Mutex<Receiver<crate::quic::CursorState>>,
|
||||
/// Inbound mid-session access updates (control-stream [`crate::quic::AccessUpdate`]) —
|
||||
/// the wake-up plane behind [`NativeClient::next_access_update`]. The live TRUTH is
|
||||
/// `access_grants` / `access_deadline_unix` below, already updated when an event lands
|
||||
/// here, so a dropped event (full queue) loses news but never accuracy.
|
||||
access: Mutex<Receiver<crate::quic::AccessUpdate>>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||
/// Bounded ([`MIC_QUEUE`]): the pump sheds stale frames oldest-first and a full queue drops
|
||||
@@ -271,6 +293,16 @@ pub struct NativeClient {
|
||||
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host
|
||||
/// did not advertise one — see [`NativeClient::mgmt_port`].
|
||||
pub mgmt_port: u16,
|
||||
/// The session's LIVE effective access grants (the [`crate::quic::GRANT_GAMEPAD`] family):
|
||||
/// seeded from the Welcome advert, moved by every mid-session
|
||||
/// [`crate::quic::AccessUpdate`] (latest wins) — see [`NativeClient::access_grants`].
|
||||
access_grants: Arc<AtomicU32>,
|
||||
/// The live access deadline (client wall clock, unix seconds; `0` = permanent) — see
|
||||
/// [`NativeClient::access_deadline_unix`].
|
||||
access_deadline_unix: Arc<AtomicU64>,
|
||||
/// The typed [`crate::reject::RejectReason`] close code a mid-session end carried
|
||||
/// (`0` = none) — see [`NativeClient::end_reject`].
|
||||
end_reject_code: Arc<AtomicU32>,
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -479,8 +511,16 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
|
||||
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
|
||||
/// (what a host shows in its pending-approval list and files this client under when approved).
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
|
||||
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
|
||||
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
|
||||
/// desktop toolkit to ask), then the usual environment fallbacks, then the OS hostname itself.
|
||||
/// Lives here (not in a client shell crate) so the C ABI's `punktfunk_connect` can share the
|
||||
/// same default.
|
||||
///
|
||||
/// The `gethostname` step is what saves the GUI clients: **no** Apple app has `COMPUTERNAME`
|
||||
/// (Windows-only) or `HOSTNAME` (a shell variable — never exported into a `launchd`-started
|
||||
/// process) in its environment, so before it every Mac, iPad, iPhone and Apple TV knocked as
|
||||
/// the literal "This device" and the console's pending list could not tell them apart. An
|
||||
/// embedder that knows a better, user-facing name should pass it explicitly instead
|
||||
/// ([`crate::abi::punktfunk_connect_ex10`]'s `device_name`) — this is only the floor.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
@@ -493,9 +533,36 @@ pub fn device_name() -> String {
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(os_hostname)
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
/// The OS hostname (`gethostname`), or `None` when it is missing/unset/useless. macOS returns
|
||||
/// the user's computer name as an mDNS host label ("Enricos-MacBook-Pro.local"), iOS/tvOS the
|
||||
/// device name — so the `.local` suffix comes off, and the placeholder answers every platform
|
||||
/// gives when nothing is configured ("localhost") is rejected: it labels nothing.
|
||||
#[cfg(unix)]
|
||||
fn os_hostname() -> Option<String> {
|
||||
let mut buf = [0u8; 256];
|
||||
// SAFETY: `gethostname` writes at most `len` bytes into the caller's buffer; this one is a
|
||||
// stack array we own and pass its true length. A truncating write may omit the NUL, which
|
||||
// the `position` fallback below covers.
|
||||
if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let s = std::str::from_utf8(&buf[..end]).ok()?.trim();
|
||||
let s = s.strip_suffix(".local").unwrap_or(s);
|
||||
(!s.is_empty() && !s.eq_ignore_ascii_case("localhost")).then(|| s.to_string())
|
||||
}
|
||||
|
||||
/// Windows has no `gethostname` without linking winsock (and `COMPUTERNAME` is always set there
|
||||
/// anyway, so the env step above never falls through to this).
|
||||
#[cfg(not(unix))]
|
||||
fn os_hostname() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
|
||||
/// handshake completes or `timeout` elapses.
|
||||
@@ -577,6 +644,8 @@ impl NativeClient {
|
||||
std::sync::mpsc::sync_channel::<crate::quic::CursorShape>(CURSOR_SHAPE_QUEUE);
|
||||
let (cursor_state_tx, cursor_state_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
|
||||
let (access_tx, access_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::AccessUpdate>(ACCESS_QUEUE);
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8));
|
||||
@@ -594,6 +663,12 @@ impl NativeClient {
|
||||
let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default()));
|
||||
// Seeded by the pump from the Welcome (before ready_tx), then follows every ack.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(0));
|
||||
// Access truth (same seeding discipline as `live_bitrate`): the pump writes the
|
||||
// Welcome advert into both before ready_tx, the control task follows every
|
||||
// AccessUpdate. GRANT_ALL/permanent here is only the pre-handshake placeholder.
|
||||
let access_grants = Arc::new(AtomicU32::new(crate::quic::GRANT_ALL));
|
||||
let access_deadline_unix = Arc::new(AtomicU64::new(0));
|
||||
let end_reject_code = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let host = host.to_string();
|
||||
let frame_chan_w = frame_chan.clone();
|
||||
@@ -610,6 +685,9 @@ impl NativeClient {
|
||||
let decode_lat_w = decode_lat.clone();
|
||||
let live_bitrate_w = live_bitrate.clone();
|
||||
let pad_audio_caps_w = pad_audio_caps.clone();
|
||||
let access_grants_w = access_grants.clone();
|
||||
let access_deadline_w = access_deadline_unix.clone();
|
||||
let end_reject_w = end_reject_code.clone();
|
||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("punktfunk-client".into())
|
||||
@@ -685,6 +763,10 @@ impl NativeClient {
|
||||
clock_offset: clock_offset_w,
|
||||
decode_lat: decode_lat_w,
|
||||
live_bitrate: live_bitrate_w,
|
||||
access_grants: access_grants_w,
|
||||
access_deadline_unix: access_deadline_w,
|
||||
access_tx,
|
||||
end_reject_code: end_reject_w,
|
||||
}));
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
@@ -716,6 +798,10 @@ impl NativeClient {
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
cursor_shape: Mutex::new(cursor_shape_rx),
|
||||
cursor_state: Mutex::new(cursor_state_rx),
|
||||
access: Mutex::new(access_rx),
|
||||
access_grants,
|
||||
access_deadline_unix,
|
||||
end_reject_code,
|
||||
input_tx,
|
||||
mic_tx,
|
||||
mic_stats,
|
||||
@@ -983,6 +1069,17 @@ impl NativeClient {
|
||||
self.end_reason() == PunktfunkEndReason::GameExited
|
||||
}
|
||||
|
||||
/// The typed [`crate::reject::RejectReason`] a MID-SESSION close carried, if any — an
|
||||
/// access expiry (`0x69`) being the case this exists for: [`end_reason`](Self::end_reason)
|
||||
/// can only file an unrecognized deliberate close under `HostError`, and "the host ended
|
||||
/// the session with an error" is the wrong sentence for "your access expired". Latches
|
||||
/// with `end_reason` (same ordering discipline); `None` for every ordinary end. The
|
||||
/// CONNECT-time rejections never land here — they surface as
|
||||
/// [`PunktfunkError::Rejected`] from [`connect`](Self::connect) itself.
|
||||
pub fn end_reject(&self) -> Option<crate::reject::RejectReason> {
|
||||
crate::reject::RejectReason::from_close_code(self.end_reject_code.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// Register the calling thread as latency-critical so a later
|
||||
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
|
||||
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
|
||||
@@ -1394,6 +1491,43 @@ impl NativeClient {
|
||||
self.mgmt_port
|
||||
}
|
||||
|
||||
/// The session's LIVE effective access grants — the [`crate::quic::GRANT_GAMEPAD`] family,
|
||||
/// seeded from the `Welcome` advert and moved by every mid-session
|
||||
/// [`crate::quic::AccessUpdate`] (latest wins). An old host advertises nothing and this
|
||||
/// reads [`crate::quic::GRANT_ALL`] — full control, the pre-grants behavior, so an
|
||||
/// embedder keying UI off it changes nothing there.
|
||||
///
|
||||
/// Courtesy truth only: the HOST enforces the mask whatever a client renders. Read it per
|
||||
/// use (one relaxed load), never cache across an [`next_access_update`](Self::next_access_update)
|
||||
/// wake.
|
||||
pub fn access_grants(&self) -> u32 {
|
||||
self.access_grants.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// When this session's access expires, as CLIENT wall clock unix seconds — `None` =
|
||||
/// permanent (today's default, and everything an old host's Welcome decodes to). Anchored
|
||||
/// client-side from the wire's relative seconds, so host/client clock skew never moves a
|
||||
/// countdown rendered from it; re-anchored by every `AccessUpdate`.
|
||||
pub fn access_deadline_unix(&self) -> Option<u64> {
|
||||
match self.access_deadline_unix.load(Ordering::Relaxed) {
|
||||
0 => None,
|
||||
d => Some(d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next mid-session [`crate::quic::AccessUpdate`] (a console edit, or the host's
|
||||
/// T−5 m / T−1 m expiry warnings). One consumer, like every plane. The live truth is
|
||||
/// already in [`access_grants`](Self::access_grants) /
|
||||
/// [`access_deadline_unix`](Self::access_deadline_unix) when this wakes — the event is the
|
||||
/// UI's cue to re-gate capture and toast, not the data's source of record.
|
||||
pub fn next_access_update(&self, timeout: Duration) -> Result<crate::quic::AccessUpdate> {
|
||||
match self.access.lock().unwrap().recv_timeout(timeout) {
|
||||
Ok(u) => Ok(u),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||
|
||||
@@ -76,6 +76,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
live_bitrate,
|
||||
access_grants,
|
||||
access_deadline_unix,
|
||||
access_tx,
|
||||
end_reject_code,
|
||||
..
|
||||
} = args;
|
||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||
@@ -88,6 +92,15 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Same discipline for the live encoder target: the Welcome resolve is the starting truth
|
||||
// (0 against an old host that reports none); every BitrateChanged ack moves it from there.
|
||||
live_bitrate.store(negotiated.bitrate_kbps, Ordering::Relaxed);
|
||||
// …and for the live access truth: the Welcome advert seeds both slots before the embedder
|
||||
// can observe the client, so `access_grants()` never reads a pre-handshake GRANT_ALL on a
|
||||
// limited session. The deadline is anchored to the CLIENT's wall clock here — the wire
|
||||
// carries a relative `expires_in_secs`, so host/client skew never moves the countdown.
|
||||
access_grants.store(negotiated.grants, Ordering::Relaxed);
|
||||
access_deadline_unix.store(
|
||||
access_deadline_from(wall_clock_ns(), negotiated.expires_in_secs),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
// Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
|
||||
// reset its staleness counters and re-arm the clock-based jump-to-live detector.
|
||||
let clock_gen = Arc::new(AtomicU32::new(0));
|
||||
@@ -166,6 +179,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clip_event_tx: clip_event_tx.clone(),
|
||||
cursor_shape_tx,
|
||||
mode_gen: mode_gen.clone(),
|
||||
access_grants,
|
||||
access_deadline_unix,
|
||||
access_tx,
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
@@ -205,6 +221,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a
|
||||
// client that reacts to the shutdown flag must never find the reason still unset.
|
||||
let reason = crate::client::PunktfunkEndReason::from(&why);
|
||||
// A typed rejection code on a MID-SESSION close (access expiry, and whatever the
|
||||
// vocabulary grows next) rides beside the coarse reason, same ordering discipline,
|
||||
// so the embedder's end path can say the real sentence instead of "host error".
|
||||
if let Some(r) = reject_from_close(&conn) {
|
||||
end_reject_code.store(r.close_code(), Ordering::SeqCst);
|
||||
}
|
||||
end_reason.store(reason as u8, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,17 @@ pub(super) struct ControlTask {
|
||||
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
|
||||
/// knee it was taught belong to the OLD mode.
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
/// The session's LIVE access grants ([`NativeClient::access_grants`]): every inbound
|
||||
/// [`AccessUpdate`] overwrites it (latest wins) BEFORE the event is forwarded, so a reader
|
||||
/// woken by the event never sees the pre-update mask.
|
||||
pub(super) access_grants: Arc<AtomicU32>,
|
||||
/// The live access deadline (client wall clock, unix seconds; `0` = permanent) — re-anchored
|
||||
/// from every `AccessUpdate`'s relative `remaining_secs`.
|
||||
pub(super) access_deadline_unix: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// Access updates → the embedder's event plane ([`NativeClient::next_access_update`]).
|
||||
/// try_send like the clipboard/cursor planes: a lagging embedder drops the oldest news,
|
||||
/// and the two live slots above already hold the latest truth it would re-derive.
|
||||
pub(super) access_tx: std::sync::mpsc::SyncSender<crate::quic::AccessUpdate>,
|
||||
}
|
||||
|
||||
impl ControlTask {
|
||||
@@ -54,6 +65,9 @@ impl ControlTask {
|
||||
clip_event_tx,
|
||||
cursor_shape_tx,
|
||||
mode_gen,
|
||||
access_grants,
|
||||
access_deadline_unix,
|
||||
access_tx,
|
||||
} = self;
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
@@ -275,6 +289,26 @@ impl ControlTask {
|
||||
"out-of-bounds shard-payload change — ignoring (no ack)"
|
||||
);
|
||||
}
|
||||
} else if let Ok(upd) = crate::quic::AccessUpdate::decode(&msg) {
|
||||
// Mid-session access change (a console edit) or an expiry warning
|
||||
// (T−5 m / T−1 m). Latest-wins per design: fold the update into the
|
||||
// live slots FIRST, then wake the embedder — the host enforces
|
||||
// regardless, this is the courtesy that lets the client release a
|
||||
// grab it no longer backs and warn before the expiry close.
|
||||
tracing::info!(
|
||||
grants = upd.grants,
|
||||
remaining_secs = upd.remaining_secs,
|
||||
"host updated this session's access"
|
||||
);
|
||||
access_grants.store(upd.grants, Ordering::Relaxed);
|
||||
access_deadline_unix.store(
|
||||
crate::client::access_deadline_from(
|
||||
wall_clock_ns(),
|
||||
upd.remaining_secs,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let _ = access_tx.try_send(upd);
|
||||
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
|
||||
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
|
||||
// an overflowing ring drops the newest shape — the next change resends.
|
||||
|
||||
@@ -256,6 +256,8 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
mgmt_port: welcome.mgmt_port,
|
||||
grants: welcome.grants,
|
||||
expires_in_secs: welcome.expires_in_secs,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
|
||||
@@ -90,6 +90,23 @@ pub(crate) struct WorkerArgs {
|
||||
/// The live encoder-target mirror (see [`NativeClient::live_bitrate_kbps`]): the worker seeds
|
||||
/// it from the Welcome; the control task updates it on every `BitrateChanged` ack.
|
||||
pub(crate) live_bitrate: Arc<AtomicU32>,
|
||||
/// The session's LIVE access grants (see [`NativeClient::access_grants`]): seeded from the
|
||||
/// Welcome advert; every [`crate::quic::AccessUpdate`] moves it (latest wins, per design).
|
||||
pub(crate) access_grants: Arc<AtomicU32>,
|
||||
/// The live access deadline as client wall clock, unix seconds; `0` = permanent. Seeded
|
||||
/// from the Welcome's `expires_in_secs`, re-anchored by every `AccessUpdate` — see
|
||||
/// [`NativeClient::access_deadline_unix`].
|
||||
pub(crate) access_deadline_unix: Arc<AtomicU64>,
|
||||
/// Inbound access updates → the embedder's event plane
|
||||
/// ([`NativeClient::next_access_update`]), pushed by the control task AFTER it folded the
|
||||
/// update into the two live slots above.
|
||||
pub(crate) access_tx: SyncSender<crate::quic::AccessUpdate>,
|
||||
/// The typed close code a MID-SESSION end carried, when it is one of the shared
|
||||
/// [`crate::reject::RejectReason`] vocabulary; `0` = none. Latched by the worker's
|
||||
/// close watch beside `end_reason`, so an access-expiry close (0x69) can render its
|
||||
/// real sentence instead of the generic host-error one — see
|
||||
/// [`NativeClient::end_reject`].
|
||||
pub(crate) end_reject_code: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||
|
||||
@@ -62,6 +62,8 @@ pub enum PunktfunkStatus {
|
||||
RejectedWireVersion = -27,
|
||||
RejectedBusy = -28,
|
||||
RejectedSetupFailed = -29,
|
||||
RejectedAccessExpired = -30,
|
||||
RejectedLaunchNotPermitted = -31,
|
||||
Panic = -99,
|
||||
}
|
||||
|
||||
@@ -91,6 +93,8 @@ impl PunktfunkError {
|
||||
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
||||
R::Busy => PunktfunkStatus::RejectedBusy,
|
||||
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
|
||||
R::AccessExpired => PunktfunkStatus::RejectedAccessExpired,
|
||||
R::LaunchNotPermitted => PunktfunkStatus::RejectedLaunchNotPermitted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,30 @@ pub use stats::Stats;
|
||||
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
|
||||
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
|
||||
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 20;
|
||||
/// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
|
||||
/// client knocks with: what the host's pending-approval list (and the web console's
|
||||
/// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
|
||||
/// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
|
||||
/// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
|
||||
/// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
|
||||
/// (a console with three of them pending showed three identical rows). A NEW symbol, not a
|
||||
/// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
|
||||
/// selects that same default. Additive and client-local: the name rides the `Hello::name` field
|
||||
/// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
|
||||
/// v22: the per-client access surface (`design/per-client-access.md` §7) —
|
||||
/// `punktfunk_connection_grants` and `punktfunk_connection_access_expires_in` read the session's
|
||||
/// LIVE access state (the `PUNKTFUNK_GRANT_*` mask and the countdown to its expiry — Welcome
|
||||
/// snapshot first, then latest-wins over every mid-session `AccessUpdate` the control task
|
||||
/// folds in), and `punktfunk_connection_end_reject` reports the typed rejection a mid-session
|
||||
/// close carried (`PUNKTFUNK_STATUS_REJECTED_*`; `0` = none), because `end_reason` can only
|
||||
/// file an access-expiry close under HOST_ERROR and that is the wrong sentence for "your
|
||||
/// access expired". NEW symbols, not widened ones — the same rule v18 states: every existing
|
||||
/// function keeps its signature and behaviour, and an embedder that never adopts any of the
|
||||
/// three is unchanged (it simply lacks the courtesy UX; the HOST enforces the grants either
|
||||
/// way). Additive and client-local: the mask, the expiry and the `AccessUpdate` message all
|
||||
/// shipped with the Welcome's trailing-field append (old peers skip them in both directions),
|
||||
/// so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 22;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -203,6 +203,28 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
|
||||
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
|
||||
const RECOVERY_POOL_MAX: usize = 512;
|
||||
|
||||
/// Byte cost a [`BlockState`] commits to the in-flight budget. Both vectors are sized from
|
||||
/// attacker-declared header fields (`data_shards`, `recovery_shards`), so a slice-streamed frame
|
||||
/// can mint thousands of distinct-index blocks while its `FrameBuf::buf` stays pinned near zero —
|
||||
/// they must be metered exactly like the buffer, or the firewall meters only half the allocation
|
||||
/// (security-review 2026-08-15 finding 11).
|
||||
fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize {
|
||||
std::mem::size_of::<BlockState>()
|
||||
+ data_shards // have_data: Vec<bool>
|
||||
+ recovery_shards * std::mem::size_of::<Option<Vec<u8>>>() // recovery slot table
|
||||
}
|
||||
|
||||
/// Everything a frame has committed to the in-flight budget: its zeroed buffer plus every block's
|
||||
/// state. Computed at each release site BEFORE any `buf` truncation, so it nets exactly against the
|
||||
/// increments made at buffer allocation and block insertion.
|
||||
fn frame_cost(f: &FrameBuf) -> usize {
|
||||
f.buf.len()
|
||||
+ f.blocks
|
||||
.values()
|
||||
.map(|b| block_state_bytes(b.data_shards, b.recovery_shards))
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
/// Client-side only.
|
||||
pub struct Reassembler {
|
||||
@@ -232,7 +254,8 @@ pub struct Reassembler {
|
||||
/// still need their own storage (data shards land straight in the frame buffer). Capped at
|
||||
/// [`RECOVERY_POOL_MAX`].
|
||||
recovery_pool: Vec<Vec<u8>>,
|
||||
/// Sum of in-flight `FrameBuf::buf` bytes across both windows (see [`IN_FLIGHT_BUF_FACTOR`]).
|
||||
/// Sum of in-flight `FrameBuf::buf` bytes PLUS per-block [`BlockState`] cost across both
|
||||
/// windows (see [`IN_FLIGHT_BUF_FACTOR`] and [`block_state_bytes`]).
|
||||
in_flight_bytes: usize,
|
||||
}
|
||||
|
||||
@@ -638,7 +661,7 @@ impl Reassembler {
|
||||
.frames
|
||||
.remove(&hdr.frame_index)
|
||||
.expect("frame entry exists");
|
||||
*in_flight_bytes -= f.buf.len();
|
||||
*in_flight_bytes -= frame_cost(&f);
|
||||
// Remember the index (with its late-shard memory, exactly like an aged-out
|
||||
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
|
||||
// count the loss — the client's recovery request is the right outcome for a
|
||||
@@ -708,17 +731,31 @@ impl Reassembler {
|
||||
} else {
|
||||
block_idx * lim.max_data_shards
|
||||
};
|
||||
let block = blocks.entry(hdr.block_index).or_insert_with(|| BlockState {
|
||||
data_shards,
|
||||
recovery_shards,
|
||||
base_shard,
|
||||
have_data: vec![false; data_shards],
|
||||
data_received: 0,
|
||||
recovery: vec![None; recovery_shards],
|
||||
recovery_received: 0,
|
||||
done: false,
|
||||
reconstructed: false,
|
||||
});
|
||||
let block = match blocks.entry(hdr.block_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
// A NEW block's state is sized from the header-declared shard counts, so gate it on
|
||||
// the same in-flight budget as the frame buffer — otherwise a slice-streamed frame
|
||||
// mints unmetered block state per distinct index (security-review 2026-08-15 #11).
|
||||
let cost = block_state_bytes(data_shards, recovery_shards);
|
||||
if *in_flight_bytes + cost > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += cost;
|
||||
e.insert(BlockState {
|
||||
data_shards,
|
||||
recovery_shards,
|
||||
base_shard,
|
||||
have_data: vec![false; data_shards],
|
||||
data_received: 0,
|
||||
recovery: vec![None; recovery_shards],
|
||||
recovery_received: 0,
|
||||
done: false,
|
||||
reconstructed: false,
|
||||
})
|
||||
}
|
||||
};
|
||||
if block.recovery_shards != recovery_shards {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
@@ -838,7 +875,7 @@ impl Reassembler {
|
||||
hdr.frame_index,
|
||||
reconstructed_shards(&done.blocks, lim.max_data_shards),
|
||||
);
|
||||
*in_flight_bytes -= done.buf.len();
|
||||
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
|
||||
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
|
||||
// Slice-progressive consumers already hold the delivered prefix — the completing
|
||||
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
|
||||
@@ -998,8 +1035,8 @@ impl ReassemblyWindow {
|
||||
// before the frame died still counted `fec_recovered_shards`, so their restored
|
||||
// shards join the late-shard memory exactly like an emitted frame's.
|
||||
completed.insert(idx, reconstructed_shards(&f.blocks, max_data_shards));
|
||||
// Release its buffer budget and reclaim its parity bufs for the pool.
|
||||
*in_flight_bytes -= f.buf.len();
|
||||
// Release its buffer budget (+ block state) and reclaim its parity bufs for the pool.
|
||||
*in_flight_bytes -= frame_cost(f);
|
||||
// Partial delivery (chunk-aligned AUs only): the buffer is already exactly
|
||||
// what the consumer needs — received shards at their final offsets, zeros
|
||||
// where shards are missing (the codec's block walk skips zero windows).
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Per-client access grants — the shared vocabulary of `design/per-client-access.md` §3.
|
||||
//!
|
||||
//! Trust used to be binary: a paired device got *everything*, forever. Grants split that into
|
||||
//! six capabilities a device may hold (a guest pad that can't type over the owner's desktop,
|
||||
//! a TV that can't read the clipboard), carried as a `u32` bitmask that is the SAME value on
|
||||
//! the wire ([`Welcome`](super::Welcome) advert, [`AccessUpdate`](super::AccessUpdate)) and in
|
||||
//! the host's trust store — no translation layer to drift. Reserved bits must be zero; the
|
||||
//! management API rejects masks with unknown bits set, and hosts never emit them.
|
||||
//!
|
||||
//! The host is the only enforcer: nothing here appears in any client→host message, so nothing
|
||||
//! a client sends can widen its grants. Client-side use of the mask (capture gating, the
|
||||
//! "Controller only" chip) is courtesy UX over the same vocabulary.
|
||||
//!
|
||||
//! [`classify`] is the default-deny mechanism for the input plane: an exhaustive, non-wildcard
|
||||
//! match from [`InputKind`] to [`GrantClass`], shared by the host's datagram filter and the
|
||||
//! clients' capture gates. A future `InputKind` that nobody classified does not compile —
|
||||
//! the compiler, not a code review, keeps a new event kind from slipping past the filter.
|
||||
|
||||
use crate::input::InputKind;
|
||||
|
||||
/// Controller input: gamepad button/axis/snapshot/remove/arrival events, plus everything that
|
||||
/// rides with a pad — rich DualSense input (0xCC motion/touchpad), pad-audio, rumble return,
|
||||
/// and virtual-pad creation itself (deny-at-setup: no bit, no uinput node).
|
||||
pub const GRANT_GAMEPAD: u32 = 1 << 0;
|
||||
/// Pointing input: mouse rel/abs + buttons, scroll, touch, and the pen plane.
|
||||
pub const GRANT_POINTER: u32 = 1 << 1;
|
||||
/// Key input: key down/up and IME-committed text.
|
||||
pub const GRANT_KEYBOARD: u32 = 1 << 2;
|
||||
/// Shared clipboard — ANDed into the operator clipboard policy, never overriding it.
|
||||
pub const GRANT_CLIPBOARD: u32 = 1 << 3;
|
||||
/// Mic injection: the mic datagram plane + the per-session mic-service attach.
|
||||
pub const GRANT_MIC: u32 = 1 << 4;
|
||||
/// Library launch: `Hello.launch` resolution (and any future in-session launch/end verbs).
|
||||
pub const GRANT_LAUNCH: u32 = 1 << 5;
|
||||
|
||||
/// Every defined grant. Also the value an *absent* mask means — a record from before grants
|
||||
/// existed (or an old host's Welcome that omits the field) is full control, so existing
|
||||
/// pairings keep today's behavior.
|
||||
pub const GRANT_ALL: u32 =
|
||||
GRANT_GAMEPAD | GRANT_POINTER | GRANT_KEYBOARD | GRANT_CLIPBOARD | GRANT_MIC | GRANT_LAUNCH;
|
||||
|
||||
/// The reserved-must-be-zero region: a mask with any of these bits set is invalid today and is
|
||||
/// rejected at the management API (never silently cleared — the caller meant *something* this
|
||||
/// host doesn't understand, and clearing would grant less than they asked for without saying so).
|
||||
pub const GRANT_RESERVED: u32 = !GRANT_ALL;
|
||||
|
||||
/// Preset: **Full control** — all bits; today's behavior and the default for absent grants.
|
||||
pub const GRANT_PRESET_FULL: u32 = GRANT_ALL;
|
||||
/// Preset: **Controller only** — the guest/co-play preset. Deliberately excludes `LAUNCH`
|
||||
/// (design §11 D2: in co-play the owner drives what runs).
|
||||
pub const GRANT_PRESET_CONTROLLER_ONLY: u32 = GRANT_GAMEPAD;
|
||||
/// Preset: **View only** — spectator; sees and hears the stream, sends nothing.
|
||||
pub const GRANT_PRESET_VIEW_ONLY: u32 = 0;
|
||||
|
||||
/// The grant a piece of traffic needs — one variant per [`GRANT_GAMEPAD`]-family bit.
|
||||
/// [`classify`] maps every input event onto the first three; the last three name the
|
||||
/// plane/message gates (clipboard coordinator, mic attach, `Hello.launch`) so their
|
||||
/// drop counters and log lines share this vocabulary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrantClass {
|
||||
Gamepad,
|
||||
Pointer,
|
||||
Keyboard,
|
||||
Clipboard,
|
||||
Mic,
|
||||
Launch,
|
||||
}
|
||||
|
||||
impl GrantClass {
|
||||
/// The grant bit that authorizes this class — the mask test is
|
||||
/// `grants & class.bit() != 0`.
|
||||
pub fn bit(self) -> u32 {
|
||||
match self {
|
||||
Self::Gamepad => GRANT_GAMEPAD,
|
||||
Self::Pointer => GRANT_POINTER,
|
||||
Self::Keyboard => GRANT_KEYBOARD,
|
||||
Self::Clipboard => GRANT_CLIPBOARD,
|
||||
Self::Mic => GRANT_MIC,
|
||||
Self::Launch => GRANT_LAUNCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which grant an input event needs before it may reach the injector.
|
||||
///
|
||||
/// Exhaustive and wildcard-free ON PURPOSE — this match IS the default-deny mechanism
|
||||
/// (design §5.3): adding an [`InputKind`] without deciding its grant class is a compile
|
||||
/// error here, not a filter hole in the field. Do not "fix" a build break by adding a
|
||||
/// `_ =>` arm; classify the new kind.
|
||||
///
|
||||
/// Only the `0xC8` event vocabulary routes through here. The mic (`0xCA`), rich-input
|
||||
/// (`0xCC`) and pen planes are gated by their *plane* tag before per-event decode — their
|
||||
/// classes are [`GrantClass::Mic`], [`GrantClass::Gamepad`] and [`GrantClass::Pointer`]
|
||||
/// by construction.
|
||||
pub fn classify(kind: InputKind) -> GrantClass {
|
||||
match kind {
|
||||
InputKind::KeyDown | InputKind::KeyUp | InputKind::TextInput => GrantClass::Keyboard,
|
||||
InputKind::MouseMove
|
||||
| InputKind::MouseMoveAbs
|
||||
| InputKind::MouseButtonDown
|
||||
| InputKind::MouseButtonUp
|
||||
| InputKind::MouseScroll
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => GrantClass::Pointer,
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => GrantClass::Gamepad,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bits_are_disjoint_and_all_covers_exactly_them() {
|
||||
let bits = [
|
||||
GRANT_GAMEPAD,
|
||||
GRANT_POINTER,
|
||||
GRANT_KEYBOARD,
|
||||
GRANT_CLIPBOARD,
|
||||
GRANT_MIC,
|
||||
GRANT_LAUNCH,
|
||||
];
|
||||
let mut acc = 0u32;
|
||||
for b in bits {
|
||||
assert_eq!(b.count_ones(), 1);
|
||||
assert_eq!(acc & b, 0, "overlapping grant bits");
|
||||
acc |= b;
|
||||
}
|
||||
assert_eq!(acc, GRANT_ALL);
|
||||
assert_eq!(GRANT_ALL & GRANT_RESERVED, 0);
|
||||
assert_eq!(GRANT_ALL | GRANT_RESERVED, u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_match_the_design() {
|
||||
// Full = everything; Controller-only = pad bit ONLY (no LAUNCH — §11 D2); View = nothing.
|
||||
assert_eq!(GRANT_PRESET_FULL, GRANT_ALL);
|
||||
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY, GRANT_GAMEPAD);
|
||||
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY & GRANT_LAUNCH, 0);
|
||||
assert_eq!(GRANT_PRESET_VIEW_ONLY, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_input_kind_classifies_per_the_design_table() {
|
||||
use GrantClass::*;
|
||||
// Walk the whole wire vocabulary via from_u8, so a new kind added to the enum AND the
|
||||
// decoder shows up here too (the classify match itself already breaks the build).
|
||||
let mut seen = 0;
|
||||
for v in 0..=u8::MAX {
|
||||
let Some(kind) = InputKind::from_u8(v) else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
let want = match kind {
|
||||
InputKind::KeyDown | InputKind::KeyUp | InputKind::TextInput => Keyboard,
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => Gamepad,
|
||||
_ => Pointer,
|
||||
};
|
||||
assert_eq!(classify(kind), want, "kind {kind:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
seen, 16,
|
||||
"InputKind wire vocabulary grew — classify the new kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_bits_round_onto_the_grant_consts() {
|
||||
assert_eq!(GrantClass::Gamepad.bit(), GRANT_GAMEPAD);
|
||||
assert_eq!(GrantClass::Pointer.bit(), GRANT_POINTER);
|
||||
assert_eq!(GrantClass::Keyboard.bit(), GRANT_KEYBOARD);
|
||||
assert_eq!(GrantClass::Clipboard.bit(), GRANT_CLIPBOARD);
|
||||
assert_eq!(GrantClass::Mic.bit(), GRANT_MIC);
|
||||
assert_eq!(GrantClass::Launch.bit(), GRANT_LAUNCH);
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,8 @@ mod tests {
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -664,6 +664,11 @@ pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
|
||||
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
pub const CLIP_REASON_NO_FILES: u8 = 4;
|
||||
/// [`ClipState::reason`]: the operator policy allows clipboard, but THIS device's access grants
|
||||
/// don't (`GRANT_CLIPBOARD` unbit — design/per-client-access.md §5.4). Distinct from
|
||||
/// [`CLIP_REASON_POLICY_DISABLED`] so the client can say "not permitted for this device" instead
|
||||
/// of "the host has clipboard off".
|
||||
pub const CLIP_REASON_NOT_PERMITTED: u8 = 5;
|
||||
|
||||
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
pub const CLIP_FETCH_OK: u8 = 0;
|
||||
@@ -1040,6 +1045,54 @@ impl CursorRenderMode {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-client access (design/per-client-access.md §4/§9) -----------------------------------
|
||||
// Mid-session grant/expiry traffic. The grant vocabulary itself lives in [`super::access`];
|
||||
// this is the one control message that carries it host → client.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Type byte of [`AccessUpdate`] (host → client): the session's effective grants or remaining
|
||||
/// lifetime changed. 0x58: the 0x50 block belongs to the cursor channel (0x50/0x51 taken),
|
||||
/// so access sits at its top, clear of both the clipboard block (0x40-0x44) and any further
|
||||
/// cursor growth.
|
||||
pub const MSG_ACCESS_UPDATE: u8 = 0x58;
|
||||
|
||||
/// `host → client` ([`MSG_ACCESS_UPDATE`]): a console edit changed this device's grants, or its
|
||||
/// temporary access is about to run out (the T−5 m / T−1 m warnings). Latest-wins and
|
||||
/// best-effort — the HOST enforces regardless; this exists so the client can re-gate capture
|
||||
/// and warn the user before the expiry close ([`ACCESS_EXPIRED_CLOSE_CODE`](crate::reject))
|
||||
/// instead of the session just ending. An older client hits its "unknown control message" arm
|
||||
/// and simply misses the courtesy.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AccessUpdate {
|
||||
/// The effective grant bitmask ([`super::GRANT_GAMEPAD`] family) — same vocabulary as the
|
||||
/// [`Welcome`](super::Welcome) advert.
|
||||
pub grants: u32,
|
||||
/// Seconds until this device's access expires; `0` = permanent (no deadline).
|
||||
pub remaining_secs: u32,
|
||||
}
|
||||
|
||||
impl AccessUpdate {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] grants[5..9] remaining_secs[9..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_ACCESS_UPDATE);
|
||||
b.extend_from_slice(&self.grants.to_le_bytes());
|
||||
b.extend_from_slice(&self.remaining_secs.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<AccessUpdate> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_ACCESS_UPDATE {
|
||||
return Err(PunktfunkError::InvalidArg("bad AccessUpdate"));
|
||||
}
|
||||
Ok(AccessUpdate {
|
||||
grants: u32::from_le_bytes(b[5..9].try_into().unwrap()),
|
||||
remaining_secs: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::Mode;
|
||||
@@ -1346,10 +1399,30 @@ mod tests {
|
||||
policy: CLIP_POLICY_TEXT,
|
||||
reason: CLIP_REASON_NO_FILES,
|
||||
},
|
||||
ClipState {
|
||||
enabled: false,
|
||||
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
|
||||
reason: CLIP_REASON_NOT_PERMITTED,
|
||||
},
|
||||
];
|
||||
for m in cases {
|
||||
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// The reason vocabulary stays collision-free: a shipped client switches on these bytes,
|
||||
// so a re-used value would mislabel refusals in the field, not fail loudly.
|
||||
let reasons = [
|
||||
CLIP_REASON_OK,
|
||||
CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||
CLIP_REASON_TAKEN_OVER,
|
||||
CLIP_REASON_POLICY_DISABLED,
|
||||
CLIP_REASON_NO_FILES,
|
||||
CLIP_REASON_NOT_PERMITTED,
|
||||
];
|
||||
for (i, a) in reasons.iter().enumerate() {
|
||||
for b in &reasons[i + 1..] {
|
||||
assert_ne!(a, b, "CLIP_REASON_* values must be distinct");
|
||||
}
|
||||
}
|
||||
// A ClipControl must not decode as a ClipState (type byte).
|
||||
assert!(ClipState::decode(
|
||||
&ClipControl {
|
||||
@@ -1518,4 +1591,32 @@ mod tests {
|
||||
// Distinct from the neighboring vocabulary.
|
||||
assert!(ClipState::decode(&s.encode()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_update_roundtrip() {
|
||||
for (grants, remaining_secs) in [
|
||||
(GRANT_ALL, 0u32), // full control, permanent
|
||||
(GRANT_PRESET_CONTROLLER_ONLY, 300), // guest at the T−5 m warning
|
||||
(GRANT_PRESET_VIEW_ONLY, 60), // spectator at T−1 m
|
||||
(GRANT_GAMEPAD | GRANT_CLIPBOARD, u32::MAX),
|
||||
] {
|
||||
let m = AccessUpdate {
|
||||
grants,
|
||||
remaining_secs,
|
||||
};
|
||||
assert_eq!(AccessUpdate::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// 0x58 stays clear of every neighbor's decoder (an old peer's dispatch chain must fall
|
||||
// through to its "unknown control message" arm), and the length is exact.
|
||||
let bytes = AccessUpdate {
|
||||
grants: GRANT_ALL,
|
||||
remaining_secs: 1,
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(bytes[4], MSG_ACCESS_UPDATE);
|
||||
assert!(ClipState::decode(&bytes).is_err());
|
||||
assert!(CursorRenderMode::decode(&bytes).is_err());
|
||||
assert!(AccessUpdate::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(AccessUpdate::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,20 @@ pub struct Welcome {
|
||||
/// than at the next free fixed offset, and emitting it forces the `cipher` placeholder — see
|
||||
/// the note in [`Welcome::encode`]. `0` when an older host omitted it.
|
||||
pub mgmt_port: u16,
|
||||
/// The session's effective access grants — the [`GRANT_GAMEPAD`](super::GRANT_GAMEPAD)-family
|
||||
/// bitmask (per-client access, `design/per-client-access.md` §7). Courtesy, not authority:
|
||||
/// the HOST enforces the mask regardless; the client uses this to not capture what can't
|
||||
/// land (no keyboard grab without the bit — "my keyboard does nothing and nobody says why"
|
||||
/// is the failure mode this prevents) and to label the session ("Controller only").
|
||||
/// Appended after `mgmt_port` as 4 trailing bytes; absent (an older host) →
|
||||
/// [`GRANT_ALL`](super::GRANT_ALL), the pre-grants behavior.
|
||||
pub grants: u32,
|
||||
/// Seconds until this device's access expires, measured when the Welcome is built; `0` =
|
||||
/// permanent (also what an older host's omission decodes to). Mid-session changes ride
|
||||
/// [`AccessUpdate`](super::AccessUpdate); the expiry itself closes with
|
||||
/// [`ACCESS_EXPIRED_CLOSE_CODE`](crate::reject). Appended after `grants` as 4 trailing
|
||||
/// bytes.
|
||||
pub expires_in_secs: u32,
|
||||
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
|
||||
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
|
||||
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
|
||||
@@ -498,15 +512,26 @@ impl Welcome {
|
||||
// handshake would break against currently-shipped clients. An explicit `cipher = 0` is
|
||||
// harmless by comparison: a current client reads AES (correct), and a pre-cipher client
|
||||
// stops before 68 regardless.
|
||||
// The access advert (grants + expiry) follows `mgmt_port`, extending the same chain:
|
||||
// emitting it forces BOTH placeholders before it — the cipher byte (as 0 = AES) and the
|
||||
// mgmt port (as 0 = not advertised, exactly what its absence decodes to) — so the two
|
||||
// u32s always land at a deterministic offset. A full-control permanent session
|
||||
// (`GRANT_ALL`, no deadline) is what every absent-field decode yields anyway, so it is
|
||||
// omitted and the common case stays byte-identical to the pre-grants wire form.
|
||||
let mgmt_present = self.mgmt_port != 0;
|
||||
if self.cipher != CIPHER_AES_128_GCM || mgmt_present {
|
||||
let access_present = self.grants != super::access::GRANT_ALL || self.expires_in_secs != 0;
|
||||
if self.cipher != CIPHER_AES_128_GCM || mgmt_present || access_present {
|
||||
b.push(self.cipher);
|
||||
if let Some(k) = &self.key_chacha {
|
||||
b.extend_from_slice(k);
|
||||
}
|
||||
if mgmt_present {
|
||||
if mgmt_present || access_present {
|
||||
b.extend_from_slice(&self.mgmt_port.to_le_bytes());
|
||||
}
|
||||
if access_present {
|
||||
b.extend_from_slice(&self.grants.to_le_bytes());
|
||||
b.extend_from_slice(&self.expires_in_secs.to_le_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -517,12 +542,14 @@ impl Welcome {
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
|
||||
// key_chacha[69..101] mgmt_port[69..71 | 101..103] (everything from compositor on is an
|
||||
// key_chacha[69..101] mgmt_port[69..71 | 101..103] grants[71..75 | 103..107]
|
||||
// expires_in_secs[75..79 | 107..111] (everything from compositor on is an
|
||||
// optional trailing byte; an older host stops earlier; cipher/key_chacha are present only
|
||||
// when ChaCha was negotiated). `mgmt_port` is the one field whose offset is NOT fixed: it
|
||||
// follows the cipher block, so it starts at 69 for an AES session and 101 when a 32-byte
|
||||
// ChaCha key precedes it. Emitting it forces the cipher byte (see `encode`), so "cipher
|
||||
// absent" and "mgmt_port present" can never both hold.
|
||||
// when ChaCha was negotiated). `mgmt_port` and the access pair are the fields whose
|
||||
// offsets are NOT fixed: they follow the cipher block, shifted by 32 when a ChaCha key
|
||||
// precedes them. Emitting a later field forces every earlier one (see `encode`), so
|
||||
// "cipher absent" and "mgmt_port present" — or "mgmt_port absent" and "grants present" —
|
||||
// can never both hold.
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -562,6 +589,18 @@ impl Welcome {
|
||||
.get(mgmt_off..mgmt_off + 2)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0);
|
||||
// The access advert trails the mgmt port. Absent (an older host, or a full-control
|
||||
// permanent session — encode omits the default) → GRANT_ALL / no deadline, which is
|
||||
// exactly the pre-grants behavior; a truncated tail is never half an advert.
|
||||
let grants_off = mgmt_off + 2;
|
||||
let grants = b
|
||||
.get(grants_off..grants_off + 4)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(super::access::GRANT_ALL);
|
||||
let expires_in_secs = b
|
||||
.get(grants_off + 4..grants_off + 8)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0);
|
||||
Ok(Welcome {
|
||||
abi_version: u32at(4),
|
||||
udp_port: u16at(8),
|
||||
@@ -630,6 +669,8 @@ impl Welcome {
|
||||
// snapshots; the client keeps sending legacy per-transition events).
|
||||
host_caps: b.get(67).copied().unwrap_or(0),
|
||||
mgmt_port,
|
||||
grants,
|
||||
expires_in_secs,
|
||||
cipher,
|
||||
key_chacha,
|
||||
})
|
||||
@@ -717,6 +758,8 @@ mod tests {
|
||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -783,6 +826,8 @@ mod tests {
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: CIPHER_AES_128_GCM,
|
||||
key_chacha: None,
|
||||
};
|
||||
@@ -868,6 +913,77 @@ mod tests {
|
||||
assert_eq!(Welcome::decode(&cenc).unwrap().mgmt_port, 0);
|
||||
// A truncated tail (one byte of the port) is not half a port: it reads as unknown.
|
||||
assert_eq!(Welcome::decode(&menc[..70]).unwrap().mgmt_port, 0);
|
||||
|
||||
// ── grants + expiry, the access advert after the mgmt port ────────────────────────────
|
||||
//
|
||||
// Same chain discipline one link further: emitting the access pair forces BOTH the
|
||||
// cipher byte (as 0 = AES) and the mgmt port (as 0 = not advertised) so the two u32s
|
||||
// land at a deterministic offset — 71..79 for AES, 103..111 behind a ChaCha key.
|
||||
let guest = Welcome {
|
||||
grants: GRANT_PRESET_CONTROLLER_ONLY,
|
||||
expires_in_secs: 4 * 3600,
|
||||
..base
|
||||
};
|
||||
let genc = guest.encode();
|
||||
assert_eq!(
|
||||
genc.len(),
|
||||
68 + 1 + 2 + 8,
|
||||
"cipher + mgmt placeholders + 2 u32s"
|
||||
);
|
||||
assert_eq!(genc[68], CIPHER_AES_128_GCM, "forced cipher placeholder");
|
||||
assert_eq!(
|
||||
&genc[69..71],
|
||||
&0u16.to_le_bytes(),
|
||||
"forced mgmt placeholder"
|
||||
);
|
||||
assert_eq!(&genc[71..75], &GRANT_PRESET_CONTROLLER_ONLY.to_le_bytes());
|
||||
assert_eq!(&genc[75..79], &(4u32 * 3600).to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&genc).unwrap(), guest);
|
||||
// The forced-zero mgmt placeholder decodes exactly like its absence: unknown.
|
||||
assert_eq!(Welcome::decode(&genc).unwrap().mgmt_port, 0);
|
||||
|
||||
// All three trailing features together, behind a ChaCha key: 103..111.
|
||||
let full_chain = Welcome {
|
||||
mgmt_port: 47991,
|
||||
grants: GRANT_PRESET_VIEW_ONLY,
|
||||
expires_in_secs: 60,
|
||||
cipher: CIPHER_CHACHA20_POLY1305,
|
||||
key_chacha: Some(k32),
|
||||
..base
|
||||
};
|
||||
let fenc = full_chain.encode();
|
||||
assert_eq!(fenc.len(), 68 + 1 + 32 + 2 + 8);
|
||||
assert_eq!(&fenc[103..107], &GRANT_PRESET_VIEW_ONLY.to_le_bytes());
|
||||
assert_eq!(&fenc[107..111], &60u32.to_le_bytes());
|
||||
assert_eq!(Welcome::decode(&fenc).unwrap(), full_chain);
|
||||
|
||||
// Old-welcome-decodes-with-defaults: every shorter wire form — the pre-cipher 68 bytes,
|
||||
// a cipher-only form, and a mgmt-port form — reads as full control, permanent. Grants
|
||||
// arriving as GRANT_ALL from an old host is the CORRECT meaning: that host enforces
|
||||
// nothing, exactly like a full-control session.
|
||||
for old in [&enc[..], &cenc[..], &menc[..]] {
|
||||
let w = Welcome::decode(old).unwrap();
|
||||
assert_eq!(w.grants, GRANT_ALL);
|
||||
assert_eq!(w.expires_in_secs, 0);
|
||||
}
|
||||
// A truncated advert (partial u32) is never half a mask; grants-without-expiry reads
|
||||
// the mask and leaves the deadline permanent.
|
||||
assert_eq!(Welcome::decode(&genc[..73]).unwrap().grants, GRANT_ALL);
|
||||
let g_only = Welcome::decode(&genc[..75]).unwrap();
|
||||
assert_eq!(g_only.grants, GRANT_PRESET_CONTROLLER_ONLY);
|
||||
assert_eq!(g_only.expires_in_secs, 0);
|
||||
|
||||
// New-welcome-decoded-by-old-reader semantics: a 0.29-era reader stops at the bytes it
|
||||
// knows. The mgmt-port-era reader consumes [..71] of the guest Welcome and sees a valid
|
||||
// session (cipher 0 = AES, port 0 = unknown) — the appended advert never perturbs it.
|
||||
let old_view = Welcome::decode(&genc[..71]).unwrap();
|
||||
assert_eq!(old_view.cipher, CIPHER_AES_128_GCM);
|
||||
assert_eq!(old_view.mgmt_port, 0);
|
||||
assert_eq!(old_view, base);
|
||||
|
||||
// A full-control permanent session emits NO advert — the common case stays
|
||||
// byte-identical to the pre-grants wire form (and to the pre-cipher one).
|
||||
assert_eq!(base.encode().len(), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -963,6 +1079,8 @@ mod tests {
|
||||
codec: CODEC_PYROWAVE,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -1038,6 +1156,8 @@ mod tests {
|
||||
codec: CODEC_H264,
|
||||
host_caps: 0,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
@@ -1150,6 +1270,8 @@ mod tests {
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
mgmt_port: 0,
|
||||
grants: GRANT_ALL,
|
||||
expires_in_secs: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
//!
|
||||
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
|
||||
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
|
||||
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! vocabulary, `access` the per-client grant bits + input-kind classifier,
|
||||
//! `control` the typed control + clipboard messages, `pairing` the pairing
|
||||
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC9–0xD1 plane codecs,
|
||||
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
|
||||
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
|
||||
@@ -41,6 +42,7 @@ pub const MAGIC: &[u8; 4] = b"PKF1";
|
||||
/// vice-versa, regardless of field values.
|
||||
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
|
||||
|
||||
mod access;
|
||||
mod caps;
|
||||
mod clock;
|
||||
mod control;
|
||||
@@ -68,6 +70,7 @@ pub mod clipstream;
|
||||
/// cannot reach a shared key).
|
||||
pub mod pake;
|
||||
|
||||
pub use access::*;
|
||||
pub use caps::*;
|
||||
pub use clock::*;
|
||||
pub use control::*;
|
||||
|
||||
@@ -39,6 +39,15 @@ pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
||||
/// code, a setup failure reached the client as a bare dropped connection ("control stream
|
||||
/// finished mid-frame") — indistinguishable from transport trouble.
|
||||
pub const SETUP_FAILED_CLOSE_CODE: u32 = 0x68;
|
||||
/// This device's temporary access ran out (per-client access, `design/per-client-access.md` §4)
|
||||
/// — sent when the deadline fires mid-session, and by "Expire now" in the console. Only the
|
||||
/// expiring device's sessions close with it; a reconnect lands in the console's pending list
|
||||
/// for a one-click re-grant.
|
||||
pub const ACCESS_EXPIRED_CLOSE_CODE: u32 = 0x69;
|
||||
/// The `Hello.launch` request named a game this device's grants don't cover (no `LAUNCH` bit).
|
||||
/// Refused AT the handshake — a crisp typed reason beats silently dropping the user onto a
|
||||
/// bare desktop they didn't ask for. Connecting *without* a launch request still works.
|
||||
pub const LAUNCH_NOT_PERMITTED_CLOSE_CODE: u32 = 0x6A;
|
||||
|
||||
/// Why a host turned a connection away, decoded from the QUIC application close code — the
|
||||
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
||||
@@ -68,6 +77,10 @@ pub enum RejectReason {
|
||||
/// The host admitted the connection but failed to start the stream session (host-side
|
||||
/// setup error — the host log has the specific cause).
|
||||
SetupFailed,
|
||||
/// This device's temporary access to the host has expired (per-client access).
|
||||
AccessExpired,
|
||||
/// This device's grants don't include launching games (the `LAUNCH` bit is clear).
|
||||
LaunchNotPermitted,
|
||||
}
|
||||
|
||||
impl RejectReason {
|
||||
@@ -85,6 +98,8 @@ impl RejectReason {
|
||||
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
||||
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
||||
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
|
||||
ACCESS_EXPIRED_CLOSE_CODE => Self::AccessExpired,
|
||||
LAUNCH_NOT_PERMITTED_CLOSE_CODE => Self::LaunchNotPermitted,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -102,6 +117,8 @@ impl RejectReason {
|
||||
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
||||
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
||||
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
|
||||
Self::AccessExpired => ACCESS_EXPIRED_CLOSE_CODE,
|
||||
Self::LaunchNotPermitted => LAUNCH_NOT_PERMITTED_CLOSE_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +136,8 @@ impl RejectReason {
|
||||
Self::WireVersionMismatch => "wire-version",
|
||||
Self::Busy => "busy",
|
||||
Self::SetupFailed => "setup-failed",
|
||||
Self::AccessExpired => "access-expired",
|
||||
Self::LaunchNotPermitted => "launch-not-permitted",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +157,8 @@ impl std::fmt::Display for RejectReason {
|
||||
Self::WireVersionMismatch => "client and host versions do not match",
|
||||
Self::Busy => "the host is busy with another session",
|
||||
Self::SetupFailed => "the host could not start the stream session",
|
||||
Self::AccessExpired => "your access to this host has expired",
|
||||
Self::LaunchNotPermitted => "this device is not permitted to launch games on the host",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,7 +167,7 @@ impl std::fmt::Display for RejectReason {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [RejectReason; 10] = [
|
||||
const ALL: [RejectReason; 12] = [
|
||||
RejectReason::PairingNotArmed,
|
||||
RejectReason::PairingBoundToOtherDevice,
|
||||
RejectReason::PairingRateLimited,
|
||||
@@ -157,6 +178,8 @@ mod tests {
|
||||
RejectReason::WireVersionMismatch,
|
||||
RejectReason::Busy,
|
||||
RejectReason::SetupFailed,
|
||||
RejectReason::AccessExpired,
|
||||
RejectReason::LaunchNotPermitted,
|
||||
];
|
||||
|
||||
#[test]
|
||||
@@ -177,8 +200,9 @@ mod tests {
|
||||
#[test]
|
||||
fn foreign_codes_stay_untyped() {
|
||||
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
|
||||
// never read as a host rejection.
|
||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x69, u32::MAX] {
|
||||
// never read as a host rejection. (0x69/0x6A left this list when they became the
|
||||
// access-expired / launch-not-permitted codes; 0x6B is the block's next free id.)
|
||||
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6B, 0x70, u32::MAX] {
|
||||
assert_eq!(RejectReason::from_close_code(code), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -184,6 +184,33 @@ pub enum EventKind {
|
||||
PairingCompleted { device: DeviceRef },
|
||||
#[serde(rename = "pairing.denied")]
|
||||
PairingDenied { device: DeviceRef },
|
||||
/// A device was granted access with an explicit operator choice — the approve dialog, the
|
||||
/// arm window's carried choice, or any other `add_with_access(Some)` path
|
||||
/// (design/per-client-access.md §6). A plain pairing with no choice emits only
|
||||
/// `pairing.completed` (its access is the preserved/default record, nothing was *chosen*).
|
||||
#[serde(rename = "access.granted")]
|
||||
AccessGranted {
|
||||
device: DeviceRef,
|
||||
/// The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.
|
||||
grants: u32,
|
||||
/// Absolute expiry, host wall clock unix seconds; absent = permanent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
expires_unix: Option<i64>,
|
||||
},
|
||||
/// A paired device's access was edited after the fact (the console edit sheet / extend /
|
||||
/// "expire now") — the owner's hook can say "the TV is view-only now".
|
||||
#[serde(rename = "access.changed")]
|
||||
AccessChanged {
|
||||
device: DeviceRef,
|
||||
grants: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
expires_unix: Option<i64>,
|
||||
},
|
||||
/// A device's temporary access reached its deadline and its live session was closed — "guest
|
||||
/// access ended". Emitted at deadline fire by the expiring session (a device with no live
|
||||
/// session expires silently; the console row flips to "Expired" either way).
|
||||
#[serde(rename = "access.expired")]
|
||||
AccessExpired { device: DeviceRef },
|
||||
#[serde(rename = "display.created")]
|
||||
DisplayCreated {
|
||||
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
|
||||
@@ -256,6 +283,9 @@ impl EventKind {
|
||||
EventKind::PairingPending { .. } => "pairing.pending",
|
||||
EventKind::PairingCompleted { .. } => "pairing.completed",
|
||||
EventKind::PairingDenied { .. } => "pairing.denied",
|
||||
EventKind::AccessGranted { .. } => "access.granted",
|
||||
EventKind::AccessChanged { .. } => "access.changed",
|
||||
EventKind::AccessExpired { .. } => "access.expired",
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
@@ -288,7 +318,10 @@ impl EventKind {
|
||||
}
|
||||
EventKind::PairingPending { device }
|
||||
| EventKind::PairingCompleted { device }
|
||||
| EventKind::PairingDenied { device } => Some(&device.name),
|
||||
| EventKind::PairingDenied { device }
|
||||
| EventKind::AccessGranted { device, .. }
|
||||
| EventKind::AccessChanged { device, .. }
|
||||
| EventKind::AccessExpired { device } => Some(&device.name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -300,7 +333,10 @@ impl EventKind {
|
||||
| EventKind::ClientDisconnected { client, .. } => client.fingerprint.as_deref(),
|
||||
EventKind::PairingPending { device }
|
||||
| EventKind::PairingCompleted { device }
|
||||
| EventKind::PairingDenied { device } => Some(&device.fingerprint),
|
||||
| EventKind::PairingDenied { device }
|
||||
| EventKind::AccessGranted { device, .. }
|
||||
| EventKind::AccessChanged { device, .. }
|
||||
| EventKind::AccessExpired { device } => Some(&device.fingerprint),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -318,7 +354,10 @@ impl EventKind {
|
||||
}
|
||||
EventKind::PairingPending { device }
|
||||
| EventKind::PairingCompleted { device }
|
||||
| EventKind::PairingDenied { device } => Some(device.plane),
|
||||
| EventKind::PairingDenied { device }
|
||||
| EventKind::AccessGranted { device, .. }
|
||||
| EventKind::AccessChanged { device, .. }
|
||||
| EventKind::AccessExpired { device } => Some(device.plane),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -635,6 +674,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `access.*` wire shapes (per-client access, design §6): additive-only like the rest of
|
||||
/// the catalog, and reachable by the same hook/SSE filters (`access.*`).
|
||||
#[test]
|
||||
fn access_event_wire_shapes_and_filters() {
|
||||
let device = DeviceRef {
|
||||
name: "Guest Deck".into(),
|
||||
fingerprint: "ab12".into(),
|
||||
plane: Plane::Native,
|
||||
};
|
||||
let ev = HostEvent {
|
||||
seq: 8,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::AccessGranted {
|
||||
device: device.clone(),
|
||||
grants: 1, // GRANT_GAMEPAD — controller-only
|
||||
expires_unix: Some(1_700_000_400),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":8,"ts_ms":1700000000000,"schema":1,"kind":"access.granted","device":{"name":"Guest Deck","fingerprint":"ab12","plane":"native"},"grants":1,"expires_unix":1700000400}"#
|
||||
);
|
||||
|
||||
// A permanent grant omits the expiry (not nulled) — the optional-field convention.
|
||||
let ev = HostEvent {
|
||||
seq: 9,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
schema: 1,
|
||||
kind: EventKind::AccessChanged {
|
||||
device: device.clone(),
|
||||
grants: 63,
|
||||
expires_unix: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ev).unwrap(),
|
||||
r#"{"seq":9,"ts_ms":1700000000000,"schema":1,"kind":"access.changed","device":{"name":"Guest Deck","fingerprint":"ab12","plane":"native"},"grants":63}"#
|
||||
);
|
||||
|
||||
let expired = EventKind::AccessExpired { device };
|
||||
assert_eq!(expired.name(), "access.expired");
|
||||
assert!(kind_matches("access.*", expired.name()));
|
||||
assert!(!kind_matches("pairing.*", expired.name()));
|
||||
assert_eq!(expired.client_name(), Some("Guest Deck"));
|
||||
assert_eq!(expired.fingerprint(), Some("ab12"));
|
||||
assert_eq!(expired.plane(), Some(Plane::Native));
|
||||
}
|
||||
|
||||
/// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a
|
||||
/// filterable event nobody can select is not a feature.
|
||||
#[test]
|
||||
|
||||
@@ -218,12 +218,15 @@ pub fn start(
|
||||
params: AudioParams,
|
||||
audio_cap: AudioCapSlot,
|
||||
on_lost: super::OnSessionLost,
|
||||
owner_ip: Option<std::net::IpAddr>,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
tracing::info!(?params, "audio stream starting");
|
||||
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost) {
|
||||
if let Err(e) = run(
|
||||
&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost, owner_ip,
|
||||
) {
|
||||
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
@@ -243,6 +246,7 @@ pub fn start(
|
||||
_params: AudioParams,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_on_lost: super::OnSessionLost,
|
||||
_owner_ip: Option<std::net::IpAddr>,
|
||||
) {
|
||||
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
|
||||
running.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -256,6 +260,7 @@ fn run(
|
||||
params: AudioParams,
|
||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||
on_lost: &super::OnSessionLost,
|
||||
owner_ip: Option<std::net::IpAddr>,
|
||||
) -> Result<()> {
|
||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
|
||||
@@ -265,9 +270,27 @@ fn run(
|
||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||
let mut probe = [0u8; 256];
|
||||
let (_, client) = sock
|
||||
.recv_from(&mut probe)
|
||||
.context("audio: no client ping within 10s")?;
|
||||
// Same owner-IP bind as the video plane (LaunchSession::peer_ip): only the launching peer's
|
||||
// pings are honored, so an off-path LAN peer cannot capture the audio endpoint (a DoS here, as
|
||||
// audio payload is AES-CBC under `rikey`). `None` keeps the pre-owner behavior.
|
||||
// security-review 2026-08-15 finding 1.
|
||||
let client = {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!("audio: no client ping from the launch owner within 10s");
|
||||
}
|
||||
sock.set_read_timeout(Some(remaining))?;
|
||||
let (_, src) = sock
|
||||
.recv_from(&mut probe)
|
||||
.context("audio: no client ping within 10s")?;
|
||||
if owner_ip.is_some_and(|ip| ip != src.ip()) {
|
||||
continue;
|
||||
}
|
||||
break src;
|
||||
}
|
||||
};
|
||||
sock.connect(client)
|
||||
.context("connect client audio endpoint")?;
|
||||
// Opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1); the guard keeps the
|
||||
|
||||
@@ -31,7 +31,7 @@ use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::InputEvent;
|
||||
use punktfunk_core::quic::HdrMeta;
|
||||
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -76,6 +76,159 @@ struct Running {
|
||||
thread: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// The live session's per-client access (design/per-client-access.md §8, WP13), resolved from
|
||||
/// the launch owner's cert fingerprint against the shared grants registry
|
||||
/// ([`AppState::access`]). The control thread owns it single-threadedly, so a plain `u32`
|
||||
/// stands where the native plane's `Arc<AtomicU32>` does — the idiom is otherwise WP4's:
|
||||
/// resolve at session start, fold console edits in via the watch (within one 2 ms tick), one
|
||||
/// mask test per event, and the wall-clock deadline cuts the session.
|
||||
struct SessionAccess {
|
||||
/// The launch owner's fingerprint (lowercase hex) this state was resolved for — a
|
||||
/// different owner (steal/new session) re-resolves from scratch.
|
||||
fp_hex: String,
|
||||
/// Live edits from the console arrive here (`NativePairing::subscribe`); polled per tick.
|
||||
/// `None` when no registry is wired (tests) — then the mask stays ungoverned-full forever.
|
||||
rx: Option<tokio::sync::watch::Receiver<crate::native_pairing::AccessState>>,
|
||||
/// The effective grant mask input is filtered against.
|
||||
mask: u32,
|
||||
/// Absolute expiry, host wall clock, unix seconds; `None` = permanent. Checked each tick.
|
||||
deadline: Option<i64>,
|
||||
}
|
||||
|
||||
impl SessionAccess {
|
||||
/// Resolve a session owner's access: subscribe FIRST, then fold the channel's current
|
||||
/// value, so a console edit racing this resolution lands either in the borrow or as the
|
||||
/// first change notification — never in a gap between the two (the WP3 admission order).
|
||||
fn resolve(
|
||||
registry: Option<&Arc<crate::native_pairing::NativePairing>>,
|
||||
fp_hex: String,
|
||||
) -> SessionAccess {
|
||||
let mut access = SessionAccess {
|
||||
fp_hex,
|
||||
rx: None,
|
||||
mask: GRANT_ALL,
|
||||
deadline: None,
|
||||
};
|
||||
if let Some(np) = registry {
|
||||
let rx = np.subscribe(&access.fp_hex);
|
||||
let st = *rx.borrow();
|
||||
access.rx = Some(rx);
|
||||
access.fold(st);
|
||||
}
|
||||
access
|
||||
}
|
||||
|
||||
/// Fold one watch state in — with the Moonlight reading of `revoked` (design §8): a
|
||||
/// fingerprint with no grants record is *ungoverned* (full control), because this plane's
|
||||
/// pairing authority is the GameStream cert list, whose unpair ends the session through
|
||||
/// the mgmt endpoint, not through this watch. A record that exists governs as on the
|
||||
/// native plane: its mask applies and its deadline (checked per tick) cuts the session.
|
||||
fn fold(&mut self, st: crate::native_pairing::AccessState) {
|
||||
if st.revoked {
|
||||
self.mask = GRANT_ALL;
|
||||
self.deadline = None;
|
||||
} else {
|
||||
self.mask = st.grants;
|
||||
self.deadline = st.deadline_unix;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold any pending watch edit (non-blocking; the control thread is not async).
|
||||
fn poll(&mut self) {
|
||||
if let Some(rx) = self.rx.as_mut() {
|
||||
if rx.has_changed().unwrap_or(false) {
|
||||
let st = *rx.borrow_and_update();
|
||||
self.fold(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the deadline has passed at `now` (the deadline second itself is expired — the
|
||||
/// same evaluation as the trust store's `effective`). An "expire now" console edit is just
|
||||
/// a deadline in the past arriving through the watch, so it lands here too.
|
||||
fn expired(&self, now_unix: i64) -> bool {
|
||||
self.deadline.is_some_and(|d| now_unix >= d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Quiet per-(session, grant-class) enforcement-drop accounting — the GameStream twin of the
|
||||
/// native plane's `GrantDrops` (design §5.5): one counter and ONE `warn!` per class for the
|
||||
/// whole session (per-event logging is the DoS), totals surfaced once at session end. Plain
|
||||
/// integers, not atomics: the control thread is the only writer and reader.
|
||||
struct GrantDrops {
|
||||
counts: [u64; 6],
|
||||
warned: [bool; 6],
|
||||
}
|
||||
|
||||
impl GrantDrops {
|
||||
fn new() -> GrantDrops {
|
||||
GrantDrops {
|
||||
counts: [0; 6],
|
||||
warned: [false; 6],
|
||||
}
|
||||
}
|
||||
|
||||
/// A class's slot in the fixed tables — the bit position of its grant, so the layout can
|
||||
/// never drift from the wire vocabulary.
|
||||
fn idx(class: GrantClass) -> usize {
|
||||
class.bit().trailing_zeros() as usize
|
||||
}
|
||||
|
||||
/// Count one dropped item; log only the FIRST drop of each class — the support signal for
|
||||
/// "my keyboard does nothing" from a Moonlight client, which has no grants UX at all
|
||||
/// (silent enforcement is protocol-inherent here, design §8).
|
||||
fn note(&mut self, class: GrantClass) {
|
||||
let i = Self::idx(class);
|
||||
self.counts[i] += 1;
|
||||
if !self.warned[i] {
|
||||
self.warned[i] = true;
|
||||
tracing::warn!(
|
||||
class = ?class,
|
||||
"gamestream: dropping client input this session's access grants don't cover — \
|
||||
counted; further drops of this class are silent until the session-end totals"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the session's drop totals (if any) and reset for the next session. Called from
|
||||
/// every per-session teardown arm — disconnect, host-side end, thread stop.
|
||||
fn end_of_session(&mut self) {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::new();
|
||||
for class in [
|
||||
GrantClass::Gamepad,
|
||||
GrantClass::Pointer,
|
||||
GrantClass::Keyboard,
|
||||
GrantClass::Clipboard,
|
||||
GrantClass::Mic,
|
||||
GrantClass::Launch,
|
||||
] {
|
||||
let n = self.counts[Self::idx(class)];
|
||||
if n != 0 {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
let _ = write!(out, "{class:?}={n}");
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
tracing::info!(drops = %out, "gamestream: access-grant drop totals for the session");
|
||||
}
|
||||
*self = GrantDrops::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// The one mask test standing between a decoded event class and its injector (design §5.3):
|
||||
/// `true` = inject; `false` = counted and dropped. Kept a free function so the filter the
|
||||
/// session actually runs is the thing the tests exercise.
|
||||
fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
|
||||
if mask & class.bit() != 0 {
|
||||
return true;
|
||||
}
|
||||
drops.note(class);
|
||||
false
|
||||
}
|
||||
|
||||
/// Reconcile the control port to the paired-client list: bound while at least one pairing
|
||||
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
|
||||
/// wherever the paired list changes — startup, pairing phase 4, unpair.
|
||||
@@ -119,6 +272,54 @@ pub(crate) fn sync(state: &Arc<AppState>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`rusty_enet::Socket`] that drops datagrams whose source IP is not the launch owner's.
|
||||
///
|
||||
/// `rusty_enet` 0.4.0 exposes no setter for `maximum_waiting_data` (the C default of 32 MiB of
|
||||
/// per-peer reassembly), so an off-path LAN peer that connects on 47999 can pin ~32 MiB × the
|
||||
/// `peer_limit` and occupy peer slots without ever authenticating — and the same unfiltered path
|
||||
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Filtering at the
|
||||
/// socket drops those datagrams BEFORE ENet allocates any per-peer state. The owner is read live
|
||||
/// from `launch` on each receive: before `/launch` (owner `None`) the filter passes everything,
|
||||
/// matching the plane's existing "trust the connect when no owner is captured" fallback used by
|
||||
/// the `Event::Connect` arm below. security-review 2026-08-15 findings 2 and 13.
|
||||
struct OwnerFilteredSocket {
|
||||
inner: UdpSocket,
|
||||
state: Arc<AppState>,
|
||||
}
|
||||
|
||||
impl rusty_enet::Socket for OwnerFilteredSocket {
|
||||
type Address = std::net::SocketAddr;
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn init(&mut self, opts: rusty_enet::SocketOptions) -> Result<(), std::io::Error> {
|
||||
rusty_enet::Socket::init(&mut self.inner, opts)
|
||||
}
|
||||
|
||||
fn send(&mut self, address: Self::Address, buffer: &[u8]) -> Result<usize, std::io::Error> {
|
||||
rusty_enet::Socket::send(&mut self.inner, address, buffer)
|
||||
}
|
||||
|
||||
fn receive(
|
||||
&mut self,
|
||||
buffer: &mut [u8; rusty_enet::MTU_MAX],
|
||||
) -> Result<Option<(Self::Address, rusty_enet::PacketReceived)>, std::io::Error> {
|
||||
// Loop so a dropped non-owner datagram doesn't starve a following owner datagram in the
|
||||
// same drain; the inner socket is non-blocking, so this returns `Ok(None)` on WouldBlock.
|
||||
loop {
|
||||
match rusty_enet::Socket::receive(&mut self.inner, buffer)? {
|
||||
Some((addr, received)) => {
|
||||
let owner = self.state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||||
if owner.is_some_and(|ip| ip != addr.ip()) {
|
||||
continue;
|
||||
}
|
||||
return Ok(Some((addr, received)));
|
||||
}
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the ENet control host on 47999 and service it on a dedicated thread until `stop`.
|
||||
fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
|
||||
@@ -126,7 +327,10 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
.set_nonblocking(true)
|
||||
.context("control socket nonblocking")?;
|
||||
let mut host = Host::new(
|
||||
socket,
|
||||
OwnerFilteredSocket {
|
||||
inner: socket,
|
||||
state: state.clone(),
|
||||
},
|
||||
HostSettings {
|
||||
peer_limit: 4,
|
||||
// Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad
|
||||
@@ -170,6 +374,11 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
// clears `launch` — and the key lives there — so without this copy the one message that
|
||||
// has to go out *because* the session ended could no longer be sealed.
|
||||
let mut last_key: Option<[u8; 16]> = None;
|
||||
// Per-client access (WP13): the live session's grant mask + deadline, resolved
|
||||
// from the launch owner's fingerprint; `None` while no session is live. `drops`
|
||||
// is the session's quiet enforcement accounting (counters, never per-event logs).
|
||||
let mut access: Option<SessionAccess> = None;
|
||||
let mut drops = GrantDrops::new();
|
||||
loop {
|
||||
// WP0 teardown: the last pairing was removed while we were live. Tell a
|
||||
// connected client the session is over — termination + disconnect, the same
|
||||
@@ -194,10 +403,43 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
drops.end_of_session();
|
||||
state.end_session("control stream stopped — last pairing removed");
|
||||
tracing::info!(port = CONTROL_PORT, "control: stopped (no paired clients)");
|
||||
return;
|
||||
}
|
||||
// Track the live session's access each tick (2 ms): resolve on a new owner,
|
||||
// fold any console edit in (one watch poll — cheap version check), and cut the
|
||||
// session the tick its deadline passes. Events serviced below read the folded
|
||||
// mask, so an edit reaches enforcement within one tick of the watch publish.
|
||||
let owner_fp = state.launch.lock().unwrap().and_then(|s| s.owner_fp);
|
||||
match owner_fp {
|
||||
None => access = None,
|
||||
Some(fp) => {
|
||||
let fp_hex = hex::encode(fp);
|
||||
if access.as_ref().is_none_or(|a| a.fp_hex != fp_hex) {
|
||||
access = Some(SessionAccess::resolve(state.access.get(), fp_hex));
|
||||
} else if let Some(a) = access.as_mut() {
|
||||
a.poll();
|
||||
}
|
||||
if access
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.expired(super::wall_unix_now()))
|
||||
{
|
||||
// Expiry (or an "expire now" edit) ends the session as a decision
|
||||
// — like the mgmt unpair, not like a network drop. `quit_session`
|
||||
// clears `launch`, and the host-side-ended arm below then sends
|
||||
// the TERMINATION + disconnect: GameStream has no AccessUpdate
|
||||
// vocabulary, so that close IS the whole message (design §8). The
|
||||
// nvhttp gates keep the expired record from re-launching.
|
||||
tracing::info!(
|
||||
"gamestream: session access expired — ending the session"
|
||||
);
|
||||
state.quit_session("gamestream access expired");
|
||||
access = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
loop {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
@@ -240,6 +482,8 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// Surface the session's enforcement-drop totals (WP13).
|
||||
drops.end_of_session();
|
||||
// The control stream is the session's liveness anchor — Moonlight
|
||||
// holds it for the whole stream, and ENet detects a vanished peer
|
||||
// via its reliable-ping timeout (~5–30 s), which ALSO lands here.
|
||||
@@ -252,8 +496,26 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
state.end_session("control stream disconnected");
|
||||
}
|
||||
Event::Receive {
|
||||
channel_id, packet, ..
|
||||
peer: p,
|
||||
channel_id,
|
||||
packet,
|
||||
} => {
|
||||
// Only the tracked session peer's input is honored. The owner-IP
|
||||
// socket filter already drops non-owner datagrams once a launch is
|
||||
// recorded; this is defense-in-depth for the window before the
|
||||
// owner is captured (and mirrors the `Disconnect` arm's gate) so a
|
||||
// peer that connected while `owner_ip` was `None` still cannot
|
||||
// inject keyboard/mouse/gamepad after another peer became the
|
||||
// session. security-review 2026-08-15 finding 2.
|
||||
if peer != Some(p.id()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The mask a missing SessionAccess stands in for is FULL:
|
||||
// input only decrypts under the /launch key, so a decryptable
|
||||
// event with no resolved access can only be the ≤2 ms sliver
|
||||
// between `/launch` landing and the next tick's resolve — and
|
||||
// an ungoverned (recordless) session is full-control anyway.
|
||||
on_receive(
|
||||
&state,
|
||||
channel_id,
|
||||
@@ -263,6 +525,8 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
&inj_tx,
|
||||
&mut pads,
|
||||
&mut pointer,
|
||||
access.as_ref().map(|a| a.mask).unwrap_or(GRANT_ALL),
|
||||
&mut drops,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -321,6 +585,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
hdr_sent = false;
|
||||
pads = GamepadManager::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
drops.end_of_session();
|
||||
}
|
||||
}
|
||||
// Service the pads' force-feedback protocol every tick (games block inside
|
||||
@@ -414,7 +679,8 @@ fn decode_rfi_range(pt: &[u8]) -> Option<(i64, i64)> {
|
||||
}
|
||||
|
||||
/// Handle one received control packet: decrypt it (learning the GCM scheme on the first one),
|
||||
/// decode any input event, and inject it into the host session.
|
||||
/// decode any input event, classify it against the session's grant mask (WP13 — drops are
|
||||
/// counted, not logged), and inject what the grants cover into the host session.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn on_receive(
|
||||
state: &AppState,
|
||||
@@ -425,6 +691,8 @@ fn on_receive(
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
pointer: &mut super::pen::GsPointer,
|
||||
grants: u32,
|
||||
drops: &mut GrantDrops,
|
||||
) {
|
||||
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
|
||||
return; // control traffic before /launch — no key yet
|
||||
@@ -489,18 +757,26 @@ fn on_receive(
|
||||
}
|
||||
}
|
||||
|
||||
// Controller events go to the uinput virtual pads (created on demand per the mask).
|
||||
// Controller events go to the uinput virtual pads (created on demand per the mask) —
|
||||
// gated BEFORE the manager sees them, which is also the deny-at-setup (WP4's idiom): a
|
||||
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
|
||||
// because the creating event never arrives.
|
||||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||||
pads.handle(&gp);
|
||||
if permitted(grants, GrantClass::Gamepad, drops) {
|
||||
pads.handle(&gp);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pen/touch extension events (Moonlight sends them only after seeing our feature flag):
|
||||
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
|
||||
// Pointer-class by construction (the plane tag decides, like the native pen plane).
|
||||
if let Some(p) = super::input::decode_pointer(&pt) {
|
||||
pointer.apply(&p, |ev| {
|
||||
let _ = inj_tx.send(ev);
|
||||
});
|
||||
if permitted(grants, GrantClass::Pointer, drops) {
|
||||
pointer.apply(&p, |ev| {
|
||||
let _ = inj_tx.send(ev);
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if super::input::is_pointer_magic(&pt) {
|
||||
// A pointer magic that failed the body parse — a layout mismatch against this
|
||||
@@ -529,10 +805,14 @@ fn on_receive(
|
||||
}
|
||||
|
||||
// Forward to the dedicated injector thread (it opens the backend on the first event and
|
||||
// coalesces redundant motion). A closed channel means the injector thread died at startup —
|
||||
// input is lossy, so drop silently rather than spam.
|
||||
// coalesces redundant motion) — each event past one mask test against the exhaustive
|
||||
// classifier (design §5.3), so a Controller-only Moonlight guest's keyboard/mouse is inert
|
||||
// before injection, exactly like the native datagram dispatch. A closed channel means the
|
||||
// injector thread died at startup — input is lossy, so drop silently rather than spam.
|
||||
for ev in events {
|
||||
let _ = inj_tx.send(ev);
|
||||
if permitted(grants, classify(ev.kind), drops) {
|
||||
let _ = inj_tx.send(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,4 +1206,115 @@ mod tests {
|
||||
assert_eq!(&pt[2..4], &27u16.to_le_bytes());
|
||||
assert_eq!(pt[4], 0); // disabled
|
||||
}
|
||||
|
||||
/// The WP13 acceptance at filter level: under the Controller-only mask the pad passes and
|
||||
/// keyboard/pointer are counted-and-dropped — the exact test the session's injection arms
|
||||
/// run (`permitted` is what `on_receive` calls). Also pins the quiet-accounting reset.
|
||||
#[test]
|
||||
fn controller_only_mask_passes_the_pad_and_drops_keyboard_and_pointer() {
|
||||
use punktfunk_core::input::InputKind;
|
||||
use punktfunk_core::quic::{classify, GrantClass, GRANT_PRESET_CONTROLLER_ONLY};
|
||||
let mut drops = super::GrantDrops::new();
|
||||
let mask = GRANT_PRESET_CONTROLLER_ONLY;
|
||||
// Pad events inject (and pad creation with them — deny-at-setup is upstream of this).
|
||||
assert!(super::permitted(
|
||||
mask,
|
||||
classify(InputKind::GamepadButton),
|
||||
&mut drops
|
||||
));
|
||||
// Keyboard (keys + committed text) and every pointer shape are inert.
|
||||
assert!(!super::permitted(
|
||||
mask,
|
||||
classify(InputKind::KeyDown),
|
||||
&mut drops
|
||||
));
|
||||
assert!(!super::permitted(
|
||||
mask,
|
||||
classify(InputKind::TextInput),
|
||||
&mut drops
|
||||
));
|
||||
assert!(!super::permitted(
|
||||
mask,
|
||||
classify(InputKind::MouseMove),
|
||||
&mut drops
|
||||
));
|
||||
// The pen/touch plane is Pointer-class by its plane tag.
|
||||
assert!(!super::permitted(mask, GrantClass::Pointer, &mut drops));
|
||||
assert_eq!(
|
||||
drops.counts[super::GrantDrops::idx(GrantClass::Keyboard)],
|
||||
2
|
||||
);
|
||||
assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Pointer)], 2);
|
||||
assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Gamepad)], 0);
|
||||
// Session end logs totals once and resets for the next session.
|
||||
drops.end_of_session();
|
||||
assert_eq!(drops.counts, [0u64; 6]);
|
||||
}
|
||||
|
||||
/// The session's live access state (WP13): a fingerprint with NO grants record is
|
||||
/// ungoverned (full control — the back-compat rule for existing Moonlight pairings), a
|
||||
/// record that exists governs, console edits fold in via the watch within one poll, an
|
||||
/// "expire now" edit is a past deadline through the same channel, and deleting the record
|
||||
/// returns the session to ungoverned rather than reading as a revocation (GameStream
|
||||
/// unpair ends sessions through the mgmt endpoint, not through this watch).
|
||||
#[test]
|
||||
fn session_access_resolves_folds_edits_and_expires() {
|
||||
use crate::native_pairing::{Access, NativePairing};
|
||||
use punktfunk_core::quic::{GRANT_ALL, GRANT_GAMEPAD};
|
||||
use std::sync::Arc;
|
||||
let x = 0u8;
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pf-gs-session-access-{}-{}.json",
|
||||
std::process::id(),
|
||||
&x as *const _ as usize
|
||||
));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||
let now = super::super::wall_unix_now();
|
||||
|
||||
// No registry wired (an AppState that never went through `serve`): ungoverned forever.
|
||||
let a = super::SessionAccess::resolve(None, "ab12".into());
|
||||
assert_eq!(a.mask, GRANT_ALL);
|
||||
assert!(!a.expired(now + 1_000_000));
|
||||
|
||||
// Registry wired, no record: ungoverned — a stock Moonlight pairing keeps full control.
|
||||
let mut a = super::SessionAccess::resolve(Some(&np), "ab12".into());
|
||||
assert_eq!(a.mask, GRANT_ALL);
|
||||
assert_eq!(a.deadline, None);
|
||||
|
||||
// A record created for this fingerprint (the console path) governs the live session
|
||||
// within one watch poll.
|
||||
np.add_with_access(
|
||||
"Moonlight Deck",
|
||||
"AB12", // registry keys case-insensitively, like the store
|
||||
Some(Access {
|
||||
grants: GRANT_GAMEPAD,
|
||||
expires_unix: Some(now + 60),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
a.poll();
|
||||
assert_eq!(a.mask, GRANT_GAMEPAD);
|
||||
assert!(!a.expired(now + 59));
|
||||
assert!(a.expired(now + 60), "the deadline second itself is expired");
|
||||
|
||||
// "Expire now" is just a deadline in the past arriving through the same watch.
|
||||
np.set_access(
|
||||
"ab12",
|
||||
Access {
|
||||
grants: GRANT_GAMEPAD,
|
||||
expires_unix: Some(now - 1),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
a.poll();
|
||||
assert!(a.expired(now));
|
||||
|
||||
// Deleting the record: back to ungoverned, session survives.
|
||||
assert!(np.remove("ab12").unwrap());
|
||||
a.poll();
|
||||
assert_eq!(a.mask, GRANT_ALL);
|
||||
assert!(!a.expired(now));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,12 @@ pub struct AppState {
|
||||
/// reads `is_armed()` per frame and emits samples; the same `Arc` is shared with the mgmt API
|
||||
/// and the native punktfunk/1 loops so one capture spans whichever path is streaming.
|
||||
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
/// The per-client access grants registry (design/per-client-access.md §8, WP13): the SAME
|
||||
/// registry the native plane's trust store owns, keyed by certificate fingerprint hex — it
|
||||
/// serves both paired stores. Set once by [`serve`] after the native-pairing handle exists;
|
||||
/// unset (tests, exotic embedders) the Moonlight plane treats every paired peer as
|
||||
/// ungoverned — full control, exactly the pre-grants behavior.
|
||||
pub access: std::sync::OnceLock<Arc<crate::native_pairing::NativePairing>>,
|
||||
}
|
||||
|
||||
/// Session-lost callback the media threads invoke when they detect the client is unreachable
|
||||
@@ -331,6 +337,7 @@ impl AppState {
|
||||
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
stats,
|
||||
access: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +357,7 @@ impl AppState {
|
||||
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
stats,
|
||||
access: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +421,10 @@ pub fn serve(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
|
||||
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
|
||||
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
|
||||
let _ = state.access.set(np.clone());
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
@@ -555,6 +567,18 @@ pub fn serve(
|
||||
})
|
||||
}
|
||||
|
||||
/// Host wall clock, unix seconds — the clock every per-client-access deadline is stored in and
|
||||
/// evaluated against (design/per-client-access.md §4: wall time at each check, no cached
|
||||
/// monotonic offset, so an NTP step moves a deadline with the clock). Shared by the nvhttp
|
||||
/// launch gates and the control thread's expiry check.
|
||||
#[cfg(feature = "gamestream")]
|
||||
pub(crate) fn wall_unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The name this host shows up under everywhere a human sees it: Moonlight's host tile (the
|
||||
/// serverinfo `<hostname>` element) and Punktfunk's own client lists (the mDNS service *instance*
|
||||
/// name of both adverts). `PUNKTFUNK_HOST_NAME` wins — that's the point of the knob, a box whose
|
||||
|
||||
@@ -18,6 +18,7 @@ use axum::{
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
use punktfunk_core::quic::{GRANT_ALL, GRANT_LAUNCH};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -70,6 +71,26 @@ fn peer_fp(peer: &Option<Extension<PeerCertFingerprint>>) -> Option<[u8; 32]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grant mask the verified HTTPS peer is authorized for *right now*, resolved against the
|
||||
/// shared grants registry (design/per-client-access.md §8, WP13). `None` = an EXPIRED grants
|
||||
/// record — the launch surface fails that closed exactly like an unpaired cert. A fingerprint
|
||||
/// with NO record is ungoverned (`Some(GRANT_ALL)`): the Moonlight plane's pairing authority
|
||||
/// is its own cert list, so existing pairings keep full control (plan §8 risk table) — but a
|
||||
/// record that exists (created via the console) governs. Consulted only AFTER
|
||||
/// [`peer_is_paired`], which is why a certless peer resolves to expired-shaped `None` here:
|
||||
/// it can never reach this gate with the pairing gate intact, and if it somehow did, failing
|
||||
/// closed is the right wrong answer.
|
||||
fn peer_grants(peer: &Option<Extension<PeerCertFingerprint>>, st: &AppState) -> Option<u32> {
|
||||
let Some(Extension(PeerCertFingerprint(Some(fp)))) = peer else {
|
||||
return None;
|
||||
};
|
||||
match st.access.get() {
|
||||
Some(np) => np.moonlight_effective(fp, super::wall_unix_now()),
|
||||
// No registry wired (tests / embedders that never call `serve`): pre-grants behavior.
|
||||
None => Some(GRANT_ALL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the caller may control (resume/cancel) the current launch session. `true` when there is
|
||||
/// no session (nothing to protect — keeps cancel idempotent), or the session's owner fingerprint
|
||||
/// matches the caller's. Only a paired-but-DIFFERENT client with a known, mismatching fingerprint is
|
||||
@@ -158,6 +179,22 @@ async fn h_launch(
|
||||
tracing::warn!("launch rejected — client is not paired");
|
||||
return xml(error_xml()).into_response();
|
||||
}
|
||||
// Per-client access (WP13, design §8): LAUNCH + expiry beside the pairing gate. An expired
|
||||
// grants record fails closed exactly like an unpaired cert; a Controller-only record (no
|
||||
// LAUNCH bit) is refused too — on GameStream, launch IS the session, there is no owner-
|
||||
// launched session to join. The protocol has no reject vocabulary, so the client just sees
|
||||
// the generic error and the story lives in the console (silent enforcement, accepted).
|
||||
match peer_grants(&peer, &st) {
|
||||
Some(g) if g & GRANT_LAUNCH != 0 => {}
|
||||
Some(_) => {
|
||||
tracing::warn!("launch rejected — this client's access grants do not include Launch");
|
||||
return xml(error_xml()).into_response();
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("launch rejected — this client's access has expired");
|
||||
return xml(error_xml()).into_response();
|
||||
}
|
||||
}
|
||||
let req_fp: Option<[u8; 32]> = peer_fp(&peer);
|
||||
|
||||
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
|
||||
@@ -232,6 +269,19 @@ async fn h_resume(
|
||||
tracing::warn!("resume rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
// Same access gate as `/launch` (WP13): resuming re-attaches the full input/media planes,
|
||||
// so it needs the same LAUNCH grant, and expiry fails closed like unpaired.
|
||||
match peer_grants(&peer, &st) {
|
||||
Some(g) if g & GRANT_LAUNCH != 0 => {}
|
||||
Some(_) => {
|
||||
tracing::warn!("resume rejected — this client's access grants do not include Launch");
|
||||
return xml(error_xml());
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("resume rejected — this client's access has expired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
}
|
||||
if !peer_may_control_session(&peer, &st) {
|
||||
tracing::warn!("resume rejected — caller does not own the session");
|
||||
return xml(error_xml());
|
||||
@@ -251,6 +301,15 @@ async fn h_cancel(
|
||||
tracing::warn!("cancel rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
// Expiry gates `/cancel` likewise (an expired record fails closed exactly like unpaired) —
|
||||
// but the LAUNCH bit deliberately does NOT: cancel is Moonlight's "Quit App", a teardown,
|
||||
// and `peer_may_control_session` below already restricts it to the session's owner. Denying
|
||||
// a mid-session-downgraded owner its own quit would only wedge the session it is trying to
|
||||
// end — ending sessions is what enforcement *wants*.
|
||||
if peer_grants(&peer, &st).is_none() {
|
||||
tracing::warn!("cancel rejected — this client's access has expired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
if !peer_may_control_session(&peer, &st) {
|
||||
tracing::warn!("cancel rejected — caller does not own the session");
|
||||
return xml(error_xml());
|
||||
@@ -518,4 +577,187 @@ mod tests {
|
||||
GsDecision::Reject
|
||||
));
|
||||
}
|
||||
|
||||
/// A fresh grants registry backed by a per-test temp store.
|
||||
fn test_registry(
|
||||
tag: &str,
|
||||
) -> (
|
||||
Arc<crate::native_pairing::NativePairing>,
|
||||
std::path::PathBuf,
|
||||
) {
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pf-nvhttp-access-{tag}-{}.json",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(Some(p.clone()), None, false).unwrap(),
|
||||
);
|
||||
(np, p)
|
||||
}
|
||||
|
||||
/// WP13's resolution rule at the nvhttp gate: no grants record = ungoverned full control
|
||||
/// (existing Moonlight pairings keep today's behavior); a record that exists governs; an
|
||||
/// expired record resolves `None` — the shape the handlers fail closed exactly like
|
||||
/// unpaired. Certless peers resolve `None` too (they never pass `peer_is_paired` anyway).
|
||||
#[test]
|
||||
fn peer_grants_resolution_rule() {
|
||||
use crate::native_pairing::Access;
|
||||
use punktfunk_core::quic::GRANT_GAMEPAD;
|
||||
let st = test_state();
|
||||
let der = b"grants-client-der".to_vec();
|
||||
let fp_hex = fp_of(&der);
|
||||
let peer = Some(Extension(PeerCertFingerprint(Some(fp_hex.clone()))));
|
||||
|
||||
// No registry wired (an AppState that never went through `serve`): pre-grants behavior.
|
||||
assert_eq!(peer_grants(&peer, &st), Some(GRANT_ALL));
|
||||
|
||||
let (np, store) = test_registry("rule");
|
||||
assert!(st.access.set(np.clone()).is_ok());
|
||||
// Registry wired, no record: ungoverned.
|
||||
assert_eq!(peer_grants(&peer, &st), Some(GRANT_ALL));
|
||||
// A Controller-only record governs — LAUNCH absent.
|
||||
np.add_with_access(
|
||||
"Guest",
|
||||
&fp_hex,
|
||||
Some(Access {
|
||||
grants: GRANT_GAMEPAD,
|
||||
expires_unix: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peer_grants(&peer, &st), Some(GRANT_GAMEPAD));
|
||||
assert_eq!(peer_grants(&peer, &st).unwrap() & GRANT_LAUNCH, 0);
|
||||
// Expired: the fail-closed shape.
|
||||
np.set_access(
|
||||
&fp_hex,
|
||||
Access {
|
||||
grants: GRANT_ALL,
|
||||
expires_unix: Some(super::super::wall_unix_now() - 5),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peer_grants(&peer, &st), None);
|
||||
// Certless peer: `None` — it can never reach the grants gate past `peer_is_paired`,
|
||||
// and failing closed is the right wrong answer if it somehow did.
|
||||
assert_eq!(peer_grants(&None, &st), None);
|
||||
assert_eq!(
|
||||
peer_grants(&Some(Extension(PeerCertFingerprint(None))), &st),
|
||||
None
|
||||
);
|
||||
let _ = std::fs::remove_file(&store);
|
||||
}
|
||||
|
||||
/// The WP13 acceptance at handler level: `/resume` (same gate as `/launch`) works for a
|
||||
/// paired client with no grants record (stock back-compat), refuses a Controller-only
|
||||
/// record (no LAUNCH), refuses an expired record exactly like unpaired — and `/cancel`
|
||||
/// gates on expiry only, so a re-granted limited client can still quit its own session.
|
||||
#[tokio::test]
|
||||
async fn resume_and_cancel_honor_grants_and_expiry() {
|
||||
use crate::native_pairing::Access;
|
||||
use punktfunk_core::quic::GRANT_GAMEPAD;
|
||||
|
||||
async fn body_of(resp: Response) -> String {
|
||||
let b = axum::body::to_bytes(resp.into_body(), 64 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(b.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
let st = test_state();
|
||||
let der = b"resume-grants-client".to_vec();
|
||||
let fp_hex = fp_of(&der);
|
||||
let owner_fp = punktfunk_core::quic::endpoint::cert_fingerprint(&der);
|
||||
st.paired.lock().unwrap().push(der);
|
||||
let peer = Some(Extension(PeerCertFingerprint(Some(fp_hex.clone()))));
|
||||
let (np, store) = test_registry("resume");
|
||||
assert!(st.access.set(np.clone()).is_ok());
|
||||
let session = LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
owner_fp: Some(owner_fp),
|
||||
};
|
||||
*st.launch.lock().unwrap() = Some(session);
|
||||
|
||||
// No grants record: a stock Moonlight pairing resumes exactly as today.
|
||||
let ok = body_of(
|
||||
h_resume(State(st.clone()), peer.clone())
|
||||
.await
|
||||
.into_response(),
|
||||
)
|
||||
.await;
|
||||
assert!(ok.contains("<resume>1</resume>"), "ungoverned resume: {ok}");
|
||||
|
||||
// Controller-only record: the LAUNCH bit is missing — refused.
|
||||
let now = super::super::wall_unix_now();
|
||||
np.add_with_access(
|
||||
"Guest",
|
||||
&fp_hex,
|
||||
Some(Access {
|
||||
grants: GRANT_GAMEPAD,
|
||||
expires_unix: Some(now + 3600),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let no = body_of(
|
||||
h_resume(State(st.clone()), peer.clone())
|
||||
.await
|
||||
.into_response(),
|
||||
)
|
||||
.await;
|
||||
assert!(!no.contains("<resume>1</resume>"), "no-LAUNCH resume: {no}");
|
||||
|
||||
// Expired full record: fails closed exactly like unpaired — for /resume AND /cancel.
|
||||
np.set_access(
|
||||
&fp_hex,
|
||||
Access {
|
||||
grants: GRANT_ALL,
|
||||
expires_unix: Some(now - 5),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let no = body_of(
|
||||
h_resume(State(st.clone()), peer.clone())
|
||||
.await
|
||||
.into_response(),
|
||||
)
|
||||
.await;
|
||||
assert!(!no.contains("<resume>1</resume>"), "expired resume: {no}");
|
||||
let no = body_of(
|
||||
h_cancel(State(st.clone()), peer.clone())
|
||||
.await
|
||||
.into_response(),
|
||||
)
|
||||
.await;
|
||||
assert!(!no.contains("<cancel>1</cancel>"), "expired cancel: {no}");
|
||||
assert!(
|
||||
st.launch.lock().unwrap().is_some(),
|
||||
"a refused cancel must not tear the session down"
|
||||
);
|
||||
|
||||
// Re-granted Controller-only (unexpired, still no LAUNCH): /cancel is deliberately NOT
|
||||
// LAUNCH-gated — the session's owner may always quit its own app.
|
||||
np.set_access(
|
||||
&fp_hex,
|
||||
Access {
|
||||
grants: GRANT_GAMEPAD,
|
||||
expires_unix: Some(now + 3600),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let ok = body_of(
|
||||
h_cancel(State(st.clone()), peer.clone())
|
||||
.await
|
||||
.into_response(),
|
||||
)
|
||||
.await;
|
||||
assert!(ok.contains("<cancel>1</cancel>"), "owner cancel: {ok}");
|
||||
assert!(st.launch.lock().unwrap().is_none(), "cancel tears down");
|
||||
let _ = std::fs::remove_file(&store);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user