Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80061fbf6b | ||
|
|
4676d20dc1 | ||
|
|
be0030f953 | ||
|
|
8ee963b2b0 | ||
|
|
2d15548e38 | ||
|
|
6eb5edaff4 | ||
|
|
9dde564835 | ||
|
|
b79ff45bd1 | ||
|
|
42848c56b7 | ||
|
|
39b9e9e276 | ||
|
|
79114891df | ||
|
|
d0a3eca7b8 | ||
|
|
bf741f8693 | ||
|
|
ea5afbaa8c | ||
|
|
832a5ffd8d | ||
|
|
76c677a8f8 | ||
|
|
7cb70bf6ea | ||
|
|
b6b3c10cb5 | ||
|
|
1a8fa2282f | ||
|
|
d669064dc0 | ||
|
|
d4ad8be6bf | ||
|
|
e0c10bad85 | ||
|
|
1b28a7f7f1 | ||
|
|
ceb081f045 | ||
|
|
0870f81148 | ||
|
|
f8361f3e6f | ||
|
|
e8bc10bf0c | ||
|
|
4499313749 | ||
|
|
784f880fbf | ||
|
|
8ca4c6eb0e | ||
|
|
13aa59c575 | ||
|
|
652de8b5e0 | ||
|
|
ec36597058 | ||
|
|
e5c0d6b4eb | ||
|
|
0bba8d7f8c | ||
|
|
0ead084838 |
+21
-10
@@ -676,20 +676,23 @@ jobs:
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
#
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
|
||||
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
|
||||
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
|
||||
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
|
||||
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
|
||||
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
|
||||
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
|
||||
# `clients/apple/tools/screenshots.sh macos`.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets
|
||||
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
@@ -699,6 +702,10 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
@@ -735,10 +742,10 @@ jobs:
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
@@ -746,6 +753,10 @@ jobs:
|
||||
# Independent invocations: one platform failing skips it, not the other.
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
# tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are
|
||||
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
|
||||
SCENES="01-stream 02-hosts 05-settings 03-pair" \
|
||||
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
|
||||
# plugin-kit (@punktfunk/plugin-kit).
|
||||
# * pnpm audit → clients/decky (the Steam Deck plugin).
|
||||
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
|
||||
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
|
||||
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
|
||||
# verified against the LIVE site (the docs don't build standalone) — tracked in
|
||||
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
|
||||
# * docs-site → scanned NON-blocking (continue-on-error). 2026-08-14: docs-site's own deps
|
||||
# are current (fumadocs/tanstack/react bumped; build + tsc + serve verified),
|
||||
# but every remaining advisory is pinned INSIDE @unom/ui 0.9.2's dependency
|
||||
# tree (@payloadcms/* → fast-uri/image-size/sharp, next 16.x, sass→immutable) —
|
||||
# nothing bumpable from this lockfile, and overrides would fork what the CMS
|
||||
# actually ships. The fix belongs in the @unom/ui package repo; flip this to
|
||||
# blocking after a ui release with a clean payload chain lands here.
|
||||
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
|
||||
# fails if any crate carries a license outside the allowlist — the regression
|
||||
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
|
||||
|
||||
@@ -257,6 +257,19 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three are set. Not secret: an account/profile name and a regional endpoint,
|
||||
# inert without the credentials below. The profile's verified subject is also the MSIX
|
||||
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
@@ -275,10 +288,13 @@ jobs:
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
|
||||
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
|
||||
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
|
||||
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
|
||||
# on that accident, so removing the quotes can't silently reintroduce it.
|
||||
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
|
||||
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
|
||||
@@ -20,12 +20,18 @@
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
|
||||
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
|
||||
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
|
||||
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
|
||||
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
|
||||
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
|
||||
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
|
||||
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
|
||||
# See packaging/windows/pack-host-installer.ps1.
|
||||
#
|
||||
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
|
||||
# trusted by planting that cert in the machine Root store at install time. Independent by design:
|
||||
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
|
||||
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
|
||||
#
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
@@ -415,12 +421,26 @@ jobs:
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three of these are set. Not secret: an account/profile name and a regional
|
||||
# endpoint, all inert without the credentials below, so they live here where a reviewer
|
||||
# can see which profile a release was signed by.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
|
||||
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
|
||||
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
|
||||
# which the installer then trusts as a machine root — see packaging/windows/README.md.
|
||||
# NOT moved to Azure: driver catalogs are a separate track, see that README.
|
||||
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
|
||||
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
|
||||
run: |
|
||||
@@ -452,7 +472,13 @@ jobs:
|
||||
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
|
||||
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
|
||||
# Build this incrementally, NOT as one literal: under Azure signing there is no .cer, so
|
||||
# HOST_CER_PATH is unset — and an unset $env: var is $null, which is a HARD ERROR as a hash
|
||||
# literal key ("A null key is not allowed in a hash literal"), not the empty-string key it
|
||||
# looks like it should be. The $files guard above filters the missing .cer out just fine;
|
||||
# this line ran before anything could use it and failed the whole publish step.
|
||||
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe' }
|
||||
if ($env:HOST_CER_PATH) { $aliasNames[$env:HOST_CER_PATH] = 'punktfunk-host-windows.cer' }
|
||||
foreach ($f in $files) {
|
||||
$an = $aliasNames[$f]; if (-not $an) { continue }
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
|
||||
+478
-1
@@ -12,7 +12,166 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.28.1 — in development
|
||||
## v0.28.1
|
||||
|
||||
60 commits since v0.28.0.
|
||||
|
||||
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
|
||||
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
|
||||
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.28.0 | v0.28.1 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
|
||||
| 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) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
|
||||
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
|
||||
|
||||
⚠ **The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
|
||||
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
|
||||
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
|
||||
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
|
||||
`punktfunk-host` does not compile on macOS.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
|
||||
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
|
||||
|
||||
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
|
||||
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
|
||||
keyframe-cadence fix below). Addition only.
|
||||
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
|
||||
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
|
||||
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
|
||||
and a package that carries the compositor without it is *fatal* rather than degraded. If you
|
||||
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
|
||||
|
||||
### The management API gains two collection deletes — "unpair all"
|
||||
|
||||
Clearing a host's trust store meant one row-level delete per device, each with its own
|
||||
confirmation. Two new endpoints, one per pairing plane:
|
||||
|
||||
```
|
||||
DELETE /api/v1/clients -> {"unpaired": N}
|
||||
DELETE /api/v1/native/clients -> {"unpaired": N}
|
||||
```
|
||||
|
||||
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
|
||||
write, because N deletes would rewrite and atomically rename the store N times and a failure
|
||||
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
|
||||
endpoints because they own separate trust stores with separate persistence and separate revocation
|
||||
duties.
|
||||
|
||||
Being collection deletes, they carry the single delete's revocation guarantees across the whole
|
||||
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
|
||||
control port (UDP 47999) closes, because no pairing is left to hold it open.
|
||||
|
||||
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
|
||||
already-empty store satisfies it — and the count still distinguishes three devices from none.
|
||||
|
||||
⚠ **Both are admin-token only.** The route-classification gates match on (method, path), so the
|
||||
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
|
||||
rows in the table, like every other pairing-administration route. The native endpoint answers
|
||||
**503** on a host built without that plane, which is why the console calls only the planes that
|
||||
actually have a row.
|
||||
|
||||
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
|
||||
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
|
||||
|
||||
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
|
||||
|
||||
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
|
||||
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
|
||||
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
|
||||
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
|
||||
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
|
||||
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
|
||||
nothing services, and drops to 2–5 FPS 1% lows — with the host completely idle, no controller
|
||||
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
|
||||
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
|
||||
|
||||
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
|
||||
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
|
||||
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
|
||||
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
|
||||
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
|
||||
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
|
||||
boot.
|
||||
|
||||
⚠ **Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
|
||||
control panel while no client pad is connected — that is the parked state, not a defect. The
|
||||
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
|
||||
|
||||
### An idle Windows host no longer owns the box's default microphone
|
||||
|
||||
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
|
||||
host **idle**, a locally played Helldivers 2 tanks to 2–5 FPS 1% lows, and Windows' own Sound
|
||||
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
|
||||
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
|
||||
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
|
||||
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
|
||||
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
|
||||
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
|
||||
endpoint.
|
||||
|
||||
The recording default is now **session-scoped**, exactly like the playback default has always
|
||||
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
|
||||
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
|
||||
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
|
||||
*during* a stream still records the client's mic; one launched before the stream keeps the
|
||||
operator's own microphone.
|
||||
|
||||
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
|
||||
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
|
||||
the first real microphone.
|
||||
|
||||
⚠ **Operator-visible:** outside a stream, the default recording device is now whatever you set —
|
||||
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
|
||||
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
|
||||
re-assertion used to stomp a manual choice within one mic-pump reopen).
|
||||
|
||||
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
|
||||
|
||||
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
|
||||
0.28.0-nix). The host crash-looped forever on one line:
|
||||
|
||||
```
|
||||
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
|
||||
```
|
||||
|
||||
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
|
||||
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
|
||||
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
|
||||
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
|
||||
|
||||
The failure is nastier than it sounds because every *other* listener binds first and logs success —
|
||||
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
|
||||
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
|
||||
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
|
||||
group membership and never scoped the units.
|
||||
|
||||
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
|
||||
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
|
||||
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
|
||||
ANDed and would have matched nobody. With `host.users` empty the units fall back to
|
||||
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
|
||||
host by hand, as the module header documents.
|
||||
|
||||
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
|
||||
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
|
||||
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
|
||||
module before being committed.
|
||||
|
||||
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
|
||||
|
||||
@@ -58,6 +217,39 @@ silence would be the wrong answer.
|
||||
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
|
||||
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
|
||||
|
||||
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
|
||||
|
||||
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
|
||||
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
|
||||
|
||||
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
|
||||
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
|
||||
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
|
||||
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
|
||||
ever sees it.
|
||||
|
||||
⭐ **Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
|
||||
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
|
||||
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
|
||||
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
|
||||
session on current Hyprland, not merely on old installs, and **updating the portal would not have
|
||||
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
|
||||
|
||||
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
|
||||
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
|
||||
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
|
||||
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
|
||||
two.**
|
||||
|
||||
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
|
||||
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
|
||||
only: a pin runs the same ladder, so no value can re-create the refused request.
|
||||
|
||||
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
|
||||
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
|
||||
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
|
||||
non-vacuous by planting a wrong discriminant.
|
||||
|
||||
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
|
||||
|
||||
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
|
||||
@@ -108,6 +300,84 @@ existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusi
|
||||
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
|
||||
so at `error` level instead of looking exactly like an app with no sound.
|
||||
|
||||
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
|
||||
|
||||
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
|
||||
nothing said so.
|
||||
|
||||
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
|
||||
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
|
||||
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
|
||||
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
|
||||
screen** with sound and input intact and no error anywhere.
|
||||
|
||||
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
|
||||
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
|
||||
exists precisely because it is not — so the host was left guessing from version triples, and that
|
||||
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
|
||||
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
|
||||
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
|
||||
the second case is the normal one.**
|
||||
|
||||
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
|
||||
cannot drift and the guess stops being load-bearing. It installs under **our own** name
|
||||
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
|
||||
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
|
||||
name — and the host switches the two independently within one session.
|
||||
|
||||
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
|
||||
|
||||
| state | condition | action |
|
||||
|---|---|---|
|
||||
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
|
||||
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
|
||||
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
|
||||
|
||||
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
|
||||
rather than enabling a layer that is not there.
|
||||
|
||||
⚠ **What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
|
||||
at build time, so every channel installs the `.so` at exactly that path: literal
|
||||
`/usr/lib/punktfunk` — **not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
|
||||
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
|
||||
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
|
||||
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
|
||||
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
|
||||
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
|
||||
|
||||
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
|
||||
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
|
||||
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
|
||||
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
|
||||
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
|
||||
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
|
||||
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
|
||||
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
|
||||
|
||||
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
|
||||
|
||||
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
|
||||
washed out, on the same stream.** Those are one error.
|
||||
|
||||
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
|
||||
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
|
||||
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
|
||||
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
|
||||
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
|
||||
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
|
||||
from a reference point the host had never used, flattening the content around it.
|
||||
|
||||
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
|
||||
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
|
||||
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
|
||||
|
||||
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
|
||||
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
|
||||
check fired the session launched **advertising HDR while having made an HDR10 swapchain
|
||||
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
|
||||
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
|
||||
itself is deliberately unchanged; the section above is the real fix.
|
||||
|
||||
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
|
||||
|
||||
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
|
||||
@@ -127,6 +397,200 @@ four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect
|
||||
lingered session. Banner `+pfhdr6` → `+pfhdr7` (no new capability — but "reconnect lost my game"
|
||||
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
|
||||
|
||||
### Apple — the stats overlay lied three ways, and every host-anchored number with it
|
||||
|
||||
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 17–21 ms, then a physically
|
||||
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
|
||||
corrupt any measurement taken against a host clock:
|
||||
|
||||
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
|
||||
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
|
||||
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
|
||||
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
|
||||
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
|
||||
capture-clock comparison" after an NTP step — **and a VM host steps.**
|
||||
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
|
||||
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
|
||||
live value.
|
||||
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
|
||||
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
|
||||
the impossible half of the shifted distribution and presented the surviving tail as a plausible
|
||||
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
|
||||
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
|
||||
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
|
||||
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
|
||||
disregard `e2e`/`hostnet` for that window.
|
||||
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
|
||||
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
|
||||
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
|
||||
typed `-1.0`.
|
||||
|
||||
⚠ **Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
|
||||
re-measuring rather than trusted as a baseline.
|
||||
|
||||
Two new levers ship with the tvOS present-floor investigation, both env-only:
|
||||
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
|
||||
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
|
||||
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
|
||||
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
|
||||
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
|
||||
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
|
||||
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
|
||||
otherwise had silently run stage-4, because the gate keyed on build config.
|
||||
|
||||
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
|
||||
|
||||
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
|
||||
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
|
||||
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
|
||||
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
|
||||
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
|
||||
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
|
||||
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
|
||||
of that knob would have shown no change.
|
||||
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
|
||||
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
|
||||
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
|
||||
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
|
||||
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
|
||||
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
|
||||
it, so a session that flips to HDR mid-stream still picks the mode up.
|
||||
|
||||
### Apple — the macOS device-change recovery could answer itself forever (mic on)
|
||||
|
||||
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
|
||||
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
|
||||
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
|
||||
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
|
||||
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
|
||||
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
|
||||
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
|
||||
datagram plane while video rides its own socket, the wire signature read as a network fault and
|
||||
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
|
||||
single one covers every feedback shape:
|
||||
|
||||
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
|
||||
rebuild goes straight to the split topology instead of re-running a failure that is a property
|
||||
of the device. A different default input earns exactly one fresh attempt.
|
||||
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
|
||||
ignored**: an engine stops itself before posting, so a live poster was already restarted.
|
||||
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
|
||||
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
|
||||
instead of a metronome, and the chaining itself logs a WARN that names the condition.
|
||||
|
||||
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
|
||||
client carries this, the field workaround is turning the client microphone off.
|
||||
|
||||
**And the engines no longer start on the main thread at all.** An engine start can block on the
|
||||
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
|
||||
stream's input from the main thread — so even a single legitimate device switch froze input for
|
||||
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
|
||||
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
|
||||
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
|
||||
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
|
||||
live shortly after the call, not on return, and `stats` is safe from any thread.
|
||||
|
||||
### Apple gamepad UI — a host menu, and About becomes a page
|
||||
|
||||
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
|
||||
Android consoles have had this for a while; this is the Apple port, so the three consoles are
|
||||
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
|
||||
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
|
||||
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
|
||||
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
|
||||
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
|
||||
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
|
||||
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
|
||||
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
|
||||
shortcut, not a second host.
|
||||
|
||||
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
|
||||
over the stream they just connected to, answers the question at the one moment nobody is asking it
|
||||
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
|
||||
in an About page you can open, which is also its own section rather than the last row of Interface.
|
||||
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
|
||||
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
|
||||
|
||||
⚠ **Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
|
||||
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
|
||||
across every console screen.
|
||||
|
||||
### Console UI — Skia sized its function table to the loader, not to what we promised
|
||||
|
||||
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
|
||||
cost the stats OSD and capture HUD.
|
||||
|
||||
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
|
||||
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
|
||||
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
|
||||
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
|
||||
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
|
||||
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
|
||||
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
|
||||
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
|
||||
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
|
||||
|
||||
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
|
||||
gamepad-UI library shortcut both failed to open. The presenter now publishes
|
||||
`SharedDevice::api_version` — `min(what we declared, what the loader reports)` — and
|
||||
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
|
||||
`cfg(any(linux, windows))`, so this was never Deck-specific.
|
||||
|
||||
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
|
||||
|
||||
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
|
||||
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
|
||||
through to.
|
||||
|
||||
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
|
||||
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
|
||||
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
|
||||
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
|
||||
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
|
||||
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
|
||||
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
|
||||
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
|
||||
from that same day already showed otherwise.
|
||||
|
||||
### Client stats — the stage line is a partition again
|
||||
|
||||
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
|
||||
why the parts did not sum. Fair question: they sum **without** `decode`.
|
||||
|
||||
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
|
||||
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
|
||||
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
|
||||
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
|
||||
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
|
||||
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
|
||||
as though they tiled.
|
||||
|
||||
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
|
||||
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
|
||||
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
|
||||
⚠ **Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
|
||||
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
|
||||
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
|
||||
|
||||
### Host — two warnings that named the wrong subsystem
|
||||
|
||||
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
|
||||
|
||||
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
|
||||
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
|
||||
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
|
||||
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
|
||||
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
|
||||
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
|
||||
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
|
||||
two cannot drift.
|
||||
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
|
||||
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
|
||||
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
|
||||
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
|
||||
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
|
||||
window to within a fraction of a second. The warning no longer fires for idle seconds.
|
||||
|
||||
### NixOS — the plugin runner was installed, running, and reported missing
|
||||
|
||||
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
|
||||
@@ -165,6 +629,19 @@ NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_re
|
||||
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
|
||||
real binary rather than assuming an FHS path.
|
||||
|
||||
### Documentation
|
||||
|
||||
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
|
||||
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
|
||||
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
|
||||
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
|
||||
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
|
||||
|
||||
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
|
||||
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
|
||||
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
|
||||
follow-up.**
|
||||
|
||||
---
|
||||
|
||||
## v0.28.0
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
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.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3031,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3119,7 +3119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3133,11 +3133,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3166,14 +3166,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3188,7 +3188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3196,7 +3196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3208,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3217,7 +3217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3250,7 +3250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3261,7 +3261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3272,7 +3272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3484,7 +3484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3494,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3512,7 +3512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3529,7 +3529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3543,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3561,7 +3561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3593,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3602,7 +3602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3672,7 +3672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3686,7 +3686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3709,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+5
-1
@@ -5,13 +5,17 @@ machine, so we take security reports seriously and appreciate responsible disclo
|
||||
|
||||
## Supported versions
|
||||
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
|
||||
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
|
||||
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
|
||||
we don't backport to older minor versions, so the supported versions are the latest stable release
|
||||
and the current canary build. If you're on an older build, please check that the issue still
|
||||
reproduces on the latest stable before reporting it. See
|
||||
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
|
||||
|
||||
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
|
||||
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
|
||||
that carry the fix rather than waiting on the next feature release.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please report security issues privately by email to security@punktfunk.com.**
|
||||
|
||||
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
|
||||
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
|
||||
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
|
||||
# themselves, while Debian splits it into its own -dev package.
|
||||
libx11-xcb-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
@@ -66,3 +71,13 @@ RUN set -eux; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
|
||||
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
|
||||
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
|
||||
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
|
||||
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
|
||||
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
|
||||
RUN set -eux; \
|
||||
pkg-config --exists x11-xcb \
|
||||
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
|
||||
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
|
||||
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px — the Play Store's 16:9 TV screenshot size,
|
||||
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
|
||||
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
|
||||
* prefixed `tv-` so the artifact separates the form factors.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class TvScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = "build/outputs/roborazzi"
|
||||
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/tv-$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
|
||||
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
|
||||
/// ours to restart.
|
||||
private let isOurs: (AnyObject?) -> Bool
|
||||
/// Delivered on the main queue.
|
||||
private let onChange: (Reason) -> Void
|
||||
/// Delivered on the main queue. The second argument is the engine that posted the change
|
||||
/// (`.engineConfiguration` only; nil for the HAL listener) — the owner needs the OBJECT, not
|
||||
/// just the reason, because an engine that is RUNNING when the notification lands is one the
|
||||
/// owner already restarted: acting on that echo is how a rebuild loop starts.
|
||||
private let onChange: (Reason, AnyObject?) -> Void
|
||||
|
||||
private let lock = NSLock()
|
||||
private var configObserver: NSObjectProtocol?
|
||||
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
|
||||
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
|
||||
#endif
|
||||
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
|
||||
self.isOurs = isOurs
|
||||
self.onChange = onChange
|
||||
}
|
||||
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
|
||||
let posted = note.object as AnyObject?
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.isOurs(posted) else { return }
|
||||
self.onChange(.engineConfiguration)
|
||||
self.onChange(.engineConfiguration, posted)
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
|
||||
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
|
||||
// here can even initialize). The HAL is told either way.
|
||||
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
self?.onChange(.defaultOutputDevice) // on the main queue — registered against it below
|
||||
// On the main queue — registered against it below. No engine posted this, so nil.
|
||||
self?.onChange(.defaultOutputDevice, nil)
|
||||
}
|
||||
var address = Self.defaultOutputAddress()
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
|
||||
@@ -42,7 +42,10 @@ public enum AudioDevices {
|
||||
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||
/// The device the system is currently capturing from — the key `SessionAudio`'s
|
||||
/// voice-processing gate latches a start failure against (the failure is a property of the
|
||||
/// input device, so a new device earns a fresh attempt).
|
||||
static func defaultInputDevice() -> AudioDeviceID? {
|
||||
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
|
||||
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
|
||||
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
|
||||
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
|
||||
// engines — which posted the configuration change that scheduled the next rebuild. A ~2.5 s
|
||||
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
|
||||
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
|
||||
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
|
||||
// they could not be tested without a mic and a session.
|
||||
|
||||
#if os(macOS)
|
||||
import CoreAudio
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
/// Should a rebuild try the combined (voice-processing) topology again?
|
||||
///
|
||||
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
|
||||
/// the moment: retrying it on the same device fails the same way, and the attempt is not free —
|
||||
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
|
||||
/// fallback engines. So a failure latches until the default input actually changes; a new device
|
||||
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
|
||||
struct CombinedTopologyGate {
|
||||
private var failed = false
|
||||
/// The default input device the failure was observed on — nil is a real value here ("failed
|
||||
/// with no resolvable input device"), which is why `failed` is tracked separately.
|
||||
private var failedInput: AudioDeviceID?
|
||||
|
||||
/// The combined topology failed with `input` as the default input device.
|
||||
mutating func noteFailure(input: AudioDeviceID?) {
|
||||
failed = true
|
||||
failedInput = input
|
||||
}
|
||||
|
||||
/// True when the combined topology is worth attempting with `input` as the default input
|
||||
/// device. A device change clears the latch — the answer is about the CURRENT hardware, and
|
||||
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
|
||||
/// been the mid-transition kind, and one attempt per device change cannot loop).
|
||||
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
|
||||
guard failed else { return true }
|
||||
guard input == failedInput else {
|
||||
failed = false
|
||||
failedInput = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The delay before the next engine rebuild — the base debounce/floor behaviour, plus an
|
||||
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
|
||||
///
|
||||
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
|
||||
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
|
||||
/// the heels of the last rebuild, again and again, is a rebuild answering itself — and since the
|
||||
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
|
||||
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
|
||||
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
|
||||
struct RebuildBackoff {
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
static let debounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
static let floor: TimeInterval = 0.5
|
||||
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
|
||||
static let floorCap: TimeInterval = 30
|
||||
/// A trigger this long after the last rebuild is unrelated to it — the chain resets.
|
||||
static let chainWindow: TimeInterval = 10
|
||||
|
||||
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
|
||||
private(set) var chain = 0
|
||||
private var lastRebuildAt: TimeInterval = -.infinity
|
||||
|
||||
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
|
||||
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
|
||||
/// coalesced trigger.
|
||||
mutating func delay(now: TimeInterval) -> TimeInterval {
|
||||
let since = now - lastRebuildAt
|
||||
chain = since < Self.chainWindow ? chain + 1 : 0
|
||||
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
|
||||
return max(Self.debounce, floor - since)
|
||||
}
|
||||
|
||||
/// The rebuild actually ran at `now` — the reference the next trigger's `delay` measures from.
|
||||
mutating func noteRebuild(at now: TimeInterval) {
|
||||
lastRebuildAt = now
|
||||
}
|
||||
}
|
||||
@@ -63,15 +63,24 @@ public final class SessionAudio {
|
||||
private var micMuted = false
|
||||
/// The playback jitter ring — created by whichever engine starts playback first and KEPT
|
||||
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
|
||||
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
|
||||
private var ring: AudioRing?
|
||||
/// Every engine build, start, stop and rebuild runs here, serially — and NOT on the main
|
||||
/// thread. macOS captures and sends input from the main thread, so the seconds a
|
||||
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
|
||||
/// freeze the stream's input for exactly that long — the recovery must never make the main
|
||||
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
|
||||
/// (debounce, backoff, retry ladder), which is cheap by construction.
|
||||
private let engineQueue = DispatchQueue(
|
||||
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
|
||||
/// The video plane's end-to-end meter (capture→on-glass), if the owner wired one — the
|
||||
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
|
||||
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
|
||||
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
|
||||
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
|
||||
/// like `ring`; the meter itself is internally locked and read from the drain thread.
|
||||
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
|
||||
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
|
||||
/// meter itself is internally locked and read from the drain thread.
|
||||
private var videoLatency: LatencyMeter?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
@@ -99,7 +108,8 @@ public final class SessionAudio {
|
||||
// MARK: - Device changes (see `installDeviceChangeRecovery`)
|
||||
|
||||
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
|
||||
/// started with. Main-thread confined, like the start paths that read it.
|
||||
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
|
||||
/// fires on the main queue).
|
||||
private var startConfig: StartConfig?
|
||||
private struct StartConfig {
|
||||
let speakerUID: String
|
||||
@@ -110,20 +120,23 @@ public final class SessionAudio {
|
||||
}
|
||||
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
|
||||
private var deviceWatcher: AudioDeviceWatcher?
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
|
||||
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
|
||||
/// Main-thread confined.
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
|
||||
/// platform starts asynchronously now) from "started and dead", which is what the recovery
|
||||
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
|
||||
private var enginesAttempted = false
|
||||
/// A rebuild is already on the main queue — one device switch produces a burst of triggers
|
||||
/// and they must collapse into one restart. Main-thread confined.
|
||||
private var rebuildQueued = false
|
||||
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
|
||||
/// the session. Main-thread confined.
|
||||
private var lastRebuildAt: TimeInterval = 0
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
private static let rebuildDebounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
private static let rebuildFloor: TimeInterval = 0.5
|
||||
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
|
||||
/// retriggered by its predecessor — see `RebuildBackoff`). Main-thread confined.
|
||||
private var rebuildBackoff = RebuildBackoff()
|
||||
#if os(macOS)
|
||||
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
|
||||
/// a topology that deterministically fails — the retry is what turned one failure into a
|
||||
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
|
||||
/// `engineQueue`-confined, like the start paths that consult and feed it.
|
||||
private var combinedGate = CombinedTopologyGate()
|
||||
#endif
|
||||
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
@@ -151,11 +164,12 @@ public final class SessionAudio {
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
|
||||
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
|
||||
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` — so playback is
|
||||
/// live shortly after, not on return. An engine start can block on the audio server for
|
||||
/// seconds, and the caller's (main) thread is where macOS input capture lives — it must
|
||||
/// never wait. The mic may start later still if the permission prompt is pending.
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
///
|
||||
/// `videoLatency` is the session's END-TO-END latency meter (capture→on-glass). Pass it to arm
|
||||
@@ -166,26 +180,33 @@ public final class SessionAudio {
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
|
||||
videoLatency: LatencyMeter? = nil
|
||||
) {
|
||||
self.videoLatency = videoLatency
|
||||
self.videoLatency = videoLatency // before any dispatch below — startDrain reads it
|
||||
// Before any engine exists: the recovery watches the hardware, not the engines, and the
|
||||
// config it rebuilds from has to be recorded whether or not this start succeeds.
|
||||
stateLock.lock()
|
||||
startConfig = StartConfig(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
stateLock.unlock()
|
||||
installDeviceChangeRecovery(micEnabled: micEnabled)
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
// No AVAudioSession on macOS — but the engines start on `engineQueue`, never the
|
||||
// caller's (main) thread: a voice-processing start can block on the audio server for
|
||||
// seconds, and the main thread is where input capture lives.
|
||||
engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
}
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// then start the engines on `engineQueue` once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
@@ -342,12 +363,15 @@ public final class SessionAudio {
|
||||
#endif
|
||||
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
|
||||
/// block on the audio server for seconds — never the main thread); on iOS/tvOS the session
|
||||
/// is already active by the time this runs.
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
stateLock.lock()
|
||||
enginesAttempted = true // even if every path below fails — see `reviveStoppedEngines`
|
||||
stateLock.unlock()
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
@@ -356,9 +380,25 @@ public final class SessionAudio {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
// A rebuild must not re-attempt a voice-processing start that already failed on this
|
||||
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
|
||||
// fallback engines — the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
|
||||
var combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
|
||||
log.info("""
|
||||
voice processing already failed on this input device — split engines, no echo \
|
||||
cancellation
|
||||
""")
|
||||
combined = false
|
||||
}
|
||||
#else
|
||||
let combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
#endif
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
if combined {
|
||||
@@ -374,7 +414,8 @@ public final class SessionAudio {
|
||||
// drain thread carry over — see `makePlaybackChain`).
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
if combined {
|
||||
self.stateLock.lock()
|
||||
@@ -513,6 +554,17 @@ public final class SessionAudio {
|
||||
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
|
||||
/// not the device is what moves.
|
||||
///
|
||||
/// And three defenses keep the recovery from ANSWERING ITSELF — a rebuild is not a silent
|
||||
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
|
||||
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
|
||||
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
|
||||
/// churn stopped the fallback engines — audio and (via the main thread) INPUT cutting out
|
||||
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
|
||||
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
|
||||
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
|
||||
/// rebuilds that chain anyway back off exponentially instead of metronoming
|
||||
/// (`RebuildBackoff`).
|
||||
///
|
||||
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
|
||||
/// Main thread.
|
||||
private func installDeviceChangeRecovery(micEnabled: Bool) {
|
||||
@@ -523,7 +575,7 @@ public final class SessionAudio {
|
||||
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
|
||||
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
|
||||
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
|
||||
stateLock.lock()
|
||||
deviceWatcher = watcher
|
||||
stateLock.unlock()
|
||||
@@ -549,10 +601,17 @@ public final class SessionAudio {
|
||||
/// question — is playback still where it should be — but they answer it differently: an engine
|
||||
/// that told us it stopped is definitive, while the default device moving might not concern us
|
||||
/// at all.
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
|
||||
guard !flag.isStopped else { return }
|
||||
switch reason {
|
||||
case .engineConfiguration:
|
||||
// The engine stops itself BEFORE posting this — so an engine that is RUNNING when the
|
||||
// notification lands on the main queue is one a rebuild already replaced or restarted:
|
||||
// the notification is the rebuild's own echo, and answering it is how the recovery
|
||||
// loops. A change that stops the engine again after this posts again, and the HAL
|
||||
// backstop checks placement independently, so ignoring a live engine's echo can never
|
||||
// strand a stopped one.
|
||||
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
|
||||
scheduleEngineRebuild(reason: reason.rawValue)
|
||||
case .defaultOutputDevice:
|
||||
#if os(macOS)
|
||||
@@ -572,7 +631,10 @@ public final class SessionAudio {
|
||||
/// output device at the moment it connected — and leaving it silent for good. On iOS the same
|
||||
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
|
||||
private func reviveStoppedEngines(_ reason: String) {
|
||||
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
|
||||
stateLock.lock()
|
||||
let attempted = enginesAttempted
|
||||
stateLock.unlock()
|
||||
guard !flag.isStopped, attempted, !playbackIsLive else { return }
|
||||
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
|
||||
}
|
||||
|
||||
@@ -594,15 +656,43 @@ public final class SessionAudio {
|
||||
private func scheduleEngineRebuild(reason: String) {
|
||||
guard !rebuildQueued else { return }
|
||||
rebuildQueued = true
|
||||
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
|
||||
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
|
||||
if rebuildBackoff.chain >= 2 {
|
||||
// Each rebuild is retriggering the next — a feedback shape the echo guard and the
|
||||
// topology gate did not identify. Keep answering (a real recovery must not be
|
||||
// abandoned), but say what is happening: this line repeating IS the diagnosis.
|
||||
log.warning("""
|
||||
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row — \
|
||||
\(reason)); backing off \(Int(delay * 1000)) ms
|
||||
""")
|
||||
} else {
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: 0)
|
||||
self?.rebuildFire(attempt: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
|
||||
/// actual engine work to `engineQueue` — the teardown + start can block on the audio server
|
||||
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
|
||||
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
|
||||
/// being swallowed; `engineQueue` is serial, so the two never interleave.
|
||||
private func rebuildFire(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped else { return }
|
||||
stateLock.lock()
|
||||
let config = startConfig
|
||||
stateLock.unlock()
|
||||
guard let config else { return }
|
||||
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
|
||||
engineQueue.async { [weak self] in
|
||||
self?.performRebuild(config: config, attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back the topology this session was started with, on whatever hardware is there now.
|
||||
/// Runs on `engineQueue`.
|
||||
///
|
||||
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
|
||||
/// follow too: `installMicTap` reads the input's live format, and the voice processor
|
||||
@@ -610,10 +700,8 @@ public final class SessionAudio {
|
||||
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
|
||||
/// keeps decoding right through the switch and its overflow policy has already dropped
|
||||
/// everything that went stale while the engine was down.
|
||||
private func rebuildEngines(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped, let config = startConfig else { return }
|
||||
lastRebuildAt = ProcessInfo.processInfo.systemUptime
|
||||
private func performRebuild(config: StartConfig, attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
tearDownEngines()
|
||||
startEngines(
|
||||
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
|
||||
@@ -626,6 +714,18 @@ public final class SessionAudio {
|
||||
log.info("audio engines restarted on the current device")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.rebuildFailed(attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rebuild's playback did not come back (main queue) — walk the retry ladder. Retries
|
||||
/// when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
/// switch and a silent session.
|
||||
private func rebuildFailed(attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
guard attempt < Self.rebuildAttempts else {
|
||||
#if os(macOS)
|
||||
log.error("""
|
||||
@@ -637,10 +737,11 @@ public final class SessionAudio {
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
|
||||
rebuildQueued = true // holds off a trigger that would only race this ladder
|
||||
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
|
||||
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: attempt + 1)
|
||||
self?.rebuildFire(attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +887,13 @@ public final class SessionAudio {
|
||||
public let avOffsetMS: Int
|
||||
}
|
||||
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
|
||||
/// the ring's own numbers are taken under its lock, so they describe one instant).
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
|
||||
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
|
||||
/// describe one instant).
|
||||
public var stats: Stats? {
|
||||
stateLock.lock()
|
||||
let ring = self.ring
|
||||
stateLock.unlock()
|
||||
guard let s = ring?.stats else { return nil }
|
||||
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
||||
}
|
||||
@@ -813,7 +918,7 @@ public final class SessionAudio {
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
|
||||
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
|
||||
/// when the host's channel layout can't be expressed (already logged). Main thread.
|
||||
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
|
||||
private func makePlaybackChain()
|
||||
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
|
||||
{
|
||||
@@ -823,8 +928,10 @@ public final class SessionAudio {
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
stateLock.lock()
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
stateLock.unlock()
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
@@ -983,6 +1090,17 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
|
||||
/// a rebuild goes straight to the split topology instead of re-running the failure — the
|
||||
/// failed attempt is what churns the HAL and retriggers the recovery (see
|
||||
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
|
||||
/// transient route-transition kind, so nothing is latched there.
|
||||
private func noteCombinedFailure() {
|
||||
#if os(macOS)
|
||||
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One engine, both directions: engage the system voice processor on the shared IO unit
|
||||
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
|
||||
/// mic tap off its capture side. Every failure falls back to a WORKING configuration —
|
||||
@@ -1001,6 +1119,7 @@ public final class SessionAudio {
|
||||
voice processing unavailable (\(error.localizedDescription)) — separate \
|
||||
engines, no echo cancellation
|
||||
""")
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1054,6 +1173,7 @@ public final class SessionAudio {
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1064,6 +1184,7 @@ public final class SessionAudio {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
|
||||
@@ -32,7 +32,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -51,7 +51,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let stranger = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === ours }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -66,7 +66,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
watcher.stop()
|
||||
|
||||
@@ -93,7 +93,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
}
|
||||
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer {
|
||||
_ = Self.setDefaultOutput(original)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
|
||||
//
|
||||
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
|
||||
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
|
||||
// recovery ~0.6 s later — forever. Restore either defect (retry the failed topology, or keep the
|
||||
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRebuildPolicyTests: XCTestCase {
|
||||
// MARK: - RebuildBackoff
|
||||
|
||||
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
|
||||
func testFirstTriggerWaitsOnlyTheDebounce() {
|
||||
var backoff = RebuildBackoff()
|
||||
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
|
||||
/// responsiveness — the ladder must never make a HEALTHY recovery sluggish.
|
||||
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
|
||||
var backoff = RebuildBackoff()
|
||||
_ = backoff.delay(now: 1000)
|
||||
backoff.noteRebuild(at: 1000.2)
|
||||
// Chained once (a second switch soon after — legitimate, e.g. AirPods out then back in).
|
||||
_ = backoff.delay(now: 1001)
|
||||
backoff.noteRebuild(at: 1002)
|
||||
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
|
||||
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
|
||||
XCTAssertEqual(backoff.chain, 0)
|
||||
}
|
||||
|
||||
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
|
||||
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s — ~240 audio gaps.
|
||||
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
|
||||
func testAChainedLoopBacksOffToTheFloorCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
var rebuilds = 0
|
||||
var lastDelay: TimeInterval = 0
|
||||
let end: TimeInterval = 600
|
||||
while now < end {
|
||||
lastDelay = backoff.delay(now: now)
|
||||
now += lastDelay // the scheduled rebuild fires...
|
||||
backoff.noteRebuild(at: now)
|
||||
rebuilds += 1
|
||||
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
|
||||
}
|
||||
XCTAssertEqual(
|
||||
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
|
||||
"a persistent loop should settle at one rebuild per floorCap")
|
||||
XCTAssertLessThanOrEqual(
|
||||
rebuilds, 30,
|
||||
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
|
||||
+ "floor produced ~240)")
|
||||
// And the loop's END must restore responsiveness: quiet, then a real switch.
|
||||
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// The ladder's exponent is clamped — a loop that runs for hours must neither overflow nor
|
||||
/// push the interval past the cap.
|
||||
func testTheFloorNeverExceedsTheCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
for _ in 0..<1000 {
|
||||
let delay = backoff.delay(now: now)
|
||||
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
|
||||
now += delay
|
||||
backoff.noteRebuild(at: now)
|
||||
now += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// MARK: - CombinedTopologyGate
|
||||
|
||||
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
|
||||
/// device ⇒ never again.
|
||||
func testAFailureLatchesForTheDeviceItFailedOn() {
|
||||
var gate = CombinedTopologyGate()
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertFalse(gate.shouldTry(input: 42))
|
||||
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
|
||||
}
|
||||
|
||||
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
|
||||
/// and its own failure latches again — one attempt per device change can never loop.
|
||||
func testADifferentInputDeviceEarnsOneFreshAttempt() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertTrue(gate.shouldTry(input: 7))
|
||||
gate.noteFailure(input: 7)
|
||||
XCTAssertFalse(gate.shouldTry(input: 7))
|
||||
// Back to the first device: the earlier failure may have been mid-transition — one fresh
|
||||
// attempt again, not a permanent ban.
|
||||
XCTAssertTrue(gate.shouldTry(input: 42))
|
||||
}
|
||||
|
||||
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
|
||||
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: nil)
|
||||
XCTAssertFalse(gate.shouldTry(input: nil))
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -3,8 +3,14 @@
|
||||
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
|
||||
|
||||
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
|
||||
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is
|
||||
the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints
|
||||
a throwaway cert with that same subject so canary and release share a package identity).
|
||||
|
||||
Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package:
|
||||
installs of the older publisher cannot be upgraded in place and must be uninstalled first. That
|
||||
is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1
|
||||
reads the signature back off the packed .msix and fails the build if the two ever drift.
|
||||
|
||||
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
|
||||
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
|
||||
|
||||
@@ -56,34 +56,45 @@ MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
|
||||
## Signing & install
|
||||
|
||||
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
|
||||
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
|
||||
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
|
||||
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
|
||||
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
|
||||
trusted with no further prompt:
|
||||
CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account
|
||||
`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That
|
||||
chain is publicly trusted, so **there is nothing to import**:
|
||||
|
||||
```powershell
|
||||
# once per machine (elevated): trust the publisher
|
||||
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
|
||||
# install the package for your CPU (and re-run for each upgrade)
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
|
||||
```
|
||||
|
||||
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
|
||||
|
||||
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
|
||||
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
|
||||
`Microsoft.WindowsAppRuntime.2` framework.
|
||||
|
||||
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
|
||||
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
|
||||
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
|
||||
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
|
||||
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
|
||||
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
|
||||
it changes the package identity → a one-time reinstall).
|
||||
### How signing resolves
|
||||
|
||||
`pack-msix.ps1` picks a backend in this order:
|
||||
|
||||
1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all
|
||||
set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` /
|
||||
`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which
|
||||
holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile.
|
||||
Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted.
|
||||
2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`,
|
||||
public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback.
|
||||
3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all).
|
||||
|
||||
Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a
|
||||
`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway.
|
||||
|
||||
Two things about Azure mode that are easy to get wrong:
|
||||
|
||||
- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in
|
||||
about three days. An untimestamped signature therefore stops verifying within days of release, so
|
||||
the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry).
|
||||
- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity
|
||||
is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after
|
||||
signing, the script reads the signature back off the `.msix` and fails the build on any drift.
|
||||
Changing it makes a *different* package — existing installs must be uninstalled, not upgraded.
|
||||
|
||||
## Building locally
|
||||
|
||||
|
||||
@@ -13,15 +13,22 @@
|
||||
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
|
||||
|
||||
Signing cert precedence:
|
||||
0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/
|
||||
_PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is
|
||||
publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset.
|
||||
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
|
||||
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
|
||||
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
|
||||
in-process. The package installs only where that cert is trusted, so the matching public
|
||||
.cer is exported next to the .msix for the user to import (Trusted People) before install.
|
||||
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
|
||||
This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure
|
||||
(-RequireSignedCert), never a silent downgrade to a throwaway cert.
|
||||
|
||||
WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher;
|
||||
a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does
|
||||
not match the signer is not a cosmetic problem — Add-AppxPackage rejects the package outright,
|
||||
and it would only be discovered by a user trying to install the release.
|
||||
|
||||
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
|
||||
|
||||
.EXAMPLE
|
||||
@@ -36,9 +43,21 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
|
||||
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
||||
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
||||
# MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io'
|
||||
# certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8
|
||||
# with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a
|
||||
# publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal
|
||||
# on a user's machine. Verified against the real signer after signing below.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
# Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the
|
||||
# environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)
|
||||
# rather than as arguments, so they cannot leak into a process listing or a transcript.
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
|
||||
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
|
||||
)
|
||||
@@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) {
|
||||
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has
|
||||
# no installer and no fixed location — hence an explicit override first, then the paths the runner
|
||||
# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here.
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$makeappx = Find-SdkTool 'makeappx.exe'
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "makeappx: $makeappx"
|
||||
@@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
|
||||
if ($PfxBase64) {
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'selfsigned'
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
# signtool takes the account/profile from this file (/dmdf), not the command line.
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
Write-Host " dlib: $AzureDlib"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to an " +
|
||||
"interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
} elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
} elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " +
|
||||
"ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " +
|
||||
"secrets, or pass -RequireSignedCert false if this really is a test build.")
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
} else {
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
@@ -178,35 +240,80 @@ if ($PfxBase64) {
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
|
||||
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
|
||||
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
|
||||
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
if ($pubCert.Subject -ne $Publisher) {
|
||||
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
|
||||
# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users
|
||||
# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is
|
||||
# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced.
|
||||
if ($signMode -ne 'azure') {
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
}
|
||||
|
||||
# --- sign (timestamp best-effort) ---
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
|
||||
# --- sign ---
|
||||
# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
|
||||
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
|
||||
# signature stops verifying within days of shipping. Retrying without one there would produce a
|
||||
# package that installs on the runner and fails for every user that weekend — so the fallback is
|
||||
# gated on the mode rather than applied blindly.
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
} else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix))
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " +
|
||||
"signing cert is valid for ~3 days; an untimestamped signature would go untrusted " +
|
||||
"within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed — retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($msix))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
|
||||
}
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX
|
||||
# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic:
|
||||
# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we
|
||||
# happen to hold) is the only form of this check that works in every signing mode, and failing the
|
||||
# build here is the difference between a red pipeline and a release nobody can install.
|
||||
# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject
|
||||
# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx
|
||||
# subject interface varies by Windows version, and signtool has already reported success by this
|
||||
# point — turning "the check could not run" into a build break would trade a real defect we catch for
|
||||
# an imaginary one we invent.
|
||||
$signerSubject = $null
|
||||
try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { }
|
||||
if (-not $signerSubject) {
|
||||
Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " +
|
||||
"UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " +
|
||||
"`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.")
|
||||
} elseif ($signerSubject -ne $Publisher) {
|
||||
throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" +
|
||||
" signer : '$signerSubject'`n" +
|
||||
" Publisher : '$Publisher'`n" +
|
||||
"Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.")
|
||||
} else {
|
||||
Write-Host "verified signer subject matches manifest Publisher: $signerSubject"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> MSIX: $msix"
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA — nothing for users to import."
|
||||
} else {
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
}
|
||||
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
|
||||
if ($env:GITHUB_ENV) {
|
||||
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
}
|
||||
|
||||
@@ -62,10 +62,18 @@
|
||||
{
|
||||
"type": "application",
|
||||
"name": "punktfunk-gamescope",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus 3 local patches from packaging/gamescope/patches/",
|
||||
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus the local patch series from packaging/gamescope/patches/",
|
||||
"description": "Patched gamescope compositor distributed via sysext/Arch/nix channels alongside the host",
|
||||
"licenses": [{ "license": { "id": "BSD-2-Clause" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/ValveSoftware/gamescope" }]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"name": "Bun",
|
||||
"version": "1.3.14 (pinned in .gitea/workflows/windows-host.yml)",
|
||||
"description": "Portable JavaScript runtime bundled in the Windows host installer to run the web console (.output) and the plugin/script runner. Embeds JavaScriptCore (LGPL-2.1).",
|
||||
"licenses": [{ "license": { "id": "MIT" } }],
|
||||
"externalReferences": [{ "type": "vcs", "url": "https://github.com/oven-sh/bun" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Vendored & bundled components — CVE watch and update cadence
|
||||
|
||||
Due-diligence record for every third-party component that ships with Punktfunk but is
|
||||
**not** tracked by a package manager's advisory feed (CRA Art. 13(5); Annex I Part II §1).
|
||||
Everything resolved through Cargo/bun/pnpm lockfiles is already scanned weekly by
|
||||
`.gitea/workflows/audit.yml` (cargo-audit against RustSec, bun/pnpm audit) — this file
|
||||
covers what those scanners cannot see: vendored source trees, git-rev pins, and binaries
|
||||
staged into installers. The component inventory itself lives in
|
||||
`compliance/sbom/manual-components.cdx.json` and is merged into every release SBOM;
|
||||
keep the two files in sync when a component is added, removed, or re-pinned.
|
||||
|
||||
Owner for all of it: Enrico (sole maintainer). Standing cadence: **walk this table once
|
||||
per quarter and before every stable release**; act immediately on any advisory from the
|
||||
watch feeds below.
|
||||
|
||||
| Component | Where / pin | How to update | Watch |
|
||||
|---|---|---|---|
|
||||
| **pyrowave** (+ Granite, volk, Vulkan-Headers subtree) | `crates/pyrowave-sys/vendor/pyrowave`, pin = `PYROWAVE_COMMIT` in `scripts/vendor-pyrowave.sh`; exact commits recorded in `vendor/pyrowave/PUNKTFUNK-VENDOR.txt` | Bump the commit in the script, re-run it (network required; never from CI), re-apply `crates/pyrowave-sys/patches/`. ⚠️ **Bitstream changes are protocol-affecting** — the wire bit means "PyroWave as of this pin"; a bitstream-changing bump must bump the protocol version and re-diff the Apple Metal hand-port (see the script header). | GitHub releases/commits of Themaister/pyrowave + Themaister/Granite (niche projects, no CVE feed — repo watch is the feed) |
|
||||
| **libvpl** 2.17.0 | `crates/libvpl-sys/vendor/libvpl` (dispatcher statically linked; needs cmake + libclang) | Manual re-vendor from intel/libvpl at the new tag; rebuild `libvpl-sys` | Intel Security Center (INTEL-SA advisories for oneVPL/media) + intel/libvpl releases |
|
||||
| **windows-rs** git pin | `rev = acb5a1a7…` on microsoft/windows-rs (workspace `[patch]`/git deps: `windows`, `windows-reactor`, …) | Move the rev / return to crates.io once the needed fixes are released. Note: cargo-audit matches these by name+version from Cargo.lock, but a pre-release rev may not map cleanly onto RustSec advisories — treat the pin itself as the thing to retire. | RustSec (already weekly) + microsoft/windows-rs releases |
|
||||
| **usbfs-iso / uac-host** git pin | `rev = f3de1fd…` on unom-io/usbfs-iso | First-party fork — we are upstream; fix in the fork, move the rev | Own repo (issues land in our tracker) |
|
||||
| **FFmpeg** (host encode only) | Linux: system `libav*` (distro-updated, not ours to patch — but Arch soname majors can break us, see ffmpeg9 note). Windows: AMF/QSV shared DLLs staged from `FFMPEG_DIR` by `pack-host-installer.ps1`; LGPL notice bundled | Windows: rebuild/refresh the staged DLL set, ship in the next installer. Linux: nothing to ship; verify against new distro majors | ffmpeg-security announcements (ffmpeg.org security page) — a libav* CVE in decode/parse paths we use ⇒ refresh the Windows DLLs without undue delay |
|
||||
| **SDL3** | Desktop clients, dynamically linked; system-provided or bundled per platform package | Bump the bundled copy in the affected package; system copies are distro-updated | libsdl-org/SDL GitHub security advisories + releases |
|
||||
| **gamescope** + patch series | Pin in `packaging/nix/gamescope.nix` / built by `packaging/gamescope/build-punktfunk-gamescope.sh`; local patches in `packaging/gamescope/patches/` | Bump the pin, re-rebase the patch series, rebuild sysext/Arch/nix + .deb channels. ⚠️ the gamescope CI legs are best-effort: a broken patch shows up as a *missing package*, not a red build | ValveSoftware/gamescope releases + security advisories |
|
||||
| **Bun runtime** 1.3.14 | Pinned in `.gitea/workflows/windows-host.yml` (`bun-v1.3.14`); bundled portable in the Windows host installer to run the web console + plugin runner. Embeds JavaScriptCore | Bump the version string in the workflow; next installer build picks it up | oven-sh/bun releases (security notes ride in release notes) |
|
||||
|
||||
Not on this list on purpose:
|
||||
|
||||
- **VB-CABLE** — no longer bundled (audio-substrate program, 2026-08; the host mints its
|
||||
own virtual audio devices). If it ever returns, it returns to this table first.
|
||||
- **openh264 / rav1d CPU decode floor** — crates.io dependencies with vendored C/asm
|
||||
inside the `-sys` crates; cargo-audit tracks the crate advisories, and the upstream
|
||||
(Cisco openh264, memorysafety/rav1d) security feeds surface through RustSec. No
|
||||
separate manual watch needed unless we pin them to git.
|
||||
|
||||
## Security-update availability (CRA: ≥10 years)
|
||||
|
||||
Where users fetch fixes, and why old artifacts don't vanish (verified 2026-08-14):
|
||||
|
||||
- **Gitea releases + package registries** (git.unom.io): no cleanup rules configured,
|
||||
and Gitea does not expire releases or packages on its own — the full release history
|
||||
(v0.17.x through current) is still served with assets. Blobs live in the `unom-git`
|
||||
S3 bucket with an R2 mirror, and the box is restic-backed every 6 h. Old release
|
||||
assets (and their `.sha256` sidecars) therefore stay downloadable.
|
||||
- **Bazzite sysext feeds**: stable channels publish with `KEEP=0` (keep everything);
|
||||
only canary channels prune (`KEEP=6`) — see `rpm.yml` + `publish-sysext-feed.sh`.
|
||||
- **Flatpak repo** (flatpak.unom.io): published by rsync *without* `--delete`; old
|
||||
OSTree commits accumulate, both channels stay in the signed summary.
|
||||
- **Policy**: never add cleanup that deletes *security* releases; if storage pressure
|
||||
ever forces pruning, prune canary builds, never tagged stable releases. SBOMs are
|
||||
release assets, so the ≥10-year SBOM retention rides on the same guarantee.
|
||||
@@ -214,6 +214,15 @@ pub struct HostConfig {
|
||||
/// showing the wrong monitor is worse than showing none). Linux-only today; see
|
||||
/// `design/per-monitor-portal-capture.md`.
|
||||
pub capture_monitor: Option<String>,
|
||||
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
|
||||
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
|
||||
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
|
||||
/// otherwise). The pin is a preference, not a command: it still runs through
|
||||
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
|
||||
/// advertise — that closes the session rather than degrading, which is the failure this knob
|
||||
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
|
||||
/// negotiation has nothing to go on; `embedded` is the safe answer there.
|
||||
pub portal_cursor_mode: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
@@ -401,6 +410,12 @@ impl HostConfig {
|
||||
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
|
||||
// means "not set", not an unrecognised value to warn about. The spellings are parsed
|
||||
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
|
||||
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="dpms">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
]]></copyright>
|
||||
<interface name="org_kde_kwin_dpms_manager" version="1">
|
||||
<description summary="Output dpms manager">
|
||||
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
|
||||
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
|
||||
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
|
||||
In addition it allows to request a state change. A compositor is not obliged to honor it
|
||||
and will normally automatically switch back to on state.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment
|
||||
implementation detail. Regular clients must not use this protocol.
|
||||
Backward incompatible changes may be added without bumping the major
|
||||
version of the extension.
|
||||
</description>
|
||||
<request name="get">
|
||||
<description summary="Get org_kde_kwin_dpms for wl_output">
|
||||
Factory request to get the org_kde_kwin_dpms for a given wl_output.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
</interface>
|
||||
<interface name="org_kde_kwin_dpms" version="1">
|
||||
<description summary="Dpms for a wl_output">
|
||||
This interface provides information about the VESA DPMS state for a wl_output.
|
||||
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
|
||||
|
||||
On creating the resource the server will push whether DPSM is supported for the output,
|
||||
the currently used DPMS state and notifies the client through the done event once all
|
||||
states are pushed. Whenever a state changes the set of changes is committed with the
|
||||
done event.
|
||||
</description>
|
||||
<event name="supported">
|
||||
<description summary="Event indicating whether DPMS is supported on the wl_output">
|
||||
This event gets pushed on binding the resource and indicates whether the wl_output
|
||||
supports DPMS. There are operation modes of a Wayland server where DPMS might not
|
||||
make sense (e.g. nested compositors).
|
||||
</description>
|
||||
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
|
||||
</event>
|
||||
<enum name="mode">
|
||||
<entry name="On" value="0"/>
|
||||
<entry name="Standby" value="1"/>
|
||||
<entry name="Suspend" value="2"/>
|
||||
<entry name="Off" value="3"/>
|
||||
</enum>
|
||||
<event name="mode">
|
||||
<description summary="Event indicating used DPMS mode">
|
||||
This mode gets pushed on binding the resource and provides the currently used
|
||||
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
|
||||
case the value will be On.
|
||||
|
||||
The event is also pushed whenever the state changes.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="The new currently used mode"/>
|
||||
</event>
|
||||
<event name="done">
|
||||
<description summary="All changes are pushed">
|
||||
This event gets pushed on binding the resource once all other states are pushed.
|
||||
|
||||
In addition it gets pushed whenever a state changes to tell the client that all
|
||||
state changes have been pushed.
|
||||
</description>
|
||||
</event>
|
||||
<request name="set">
|
||||
<description summary="Request DPMS state change for the wl_output">
|
||||
Requests that the compositor puts the wl_output into the passed mode. The compositor
|
||||
is not obliged to change the state. In addition the compositor might leave the mode
|
||||
whenever it seems suitable. E.g. the compositor might return to On state on user input.
|
||||
|
||||
The client should not assume that the mode changed after requesting a new mode.
|
||||
Instead the client should listen for the mode event.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="Requested mode"/>
|
||||
</request>
|
||||
<request name="release" type="destructor">
|
||||
<description summary="release the dpms object"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
|
||||
@@ -824,6 +824,15 @@ pub mod admission;
|
||||
#[path = "vdisplay/linux/portal_config.rs"]
|
||||
mod portal_config;
|
||||
|
||||
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
|
||||
/// hardcoded, because a mode the backend does not advertise closes the session outright.
|
||||
///
|
||||
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
|
||||
/// integer work whose tests are the only place its behaviour is observable without a compositor,
|
||||
/// so they should run on every platform's CI rather than only where the callers compile.
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
@@ -839,6 +848,15 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub mod manager;
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
|
||||
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
self.route = route;
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
|
||||
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
|
||||
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
|
||||
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
|
||||
// the physical panel with the idle desktop for the whole stream. The KWin route disables
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
|
||||
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
/// named headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct HyprlandDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
|
||||
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
|
||||
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
|
||||
/// a black client.
|
||||
///
|
||||
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
|
||||
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -788,13 +797,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -818,6 +821,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// 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.
|
||||
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
|
||||
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
|
||||
// 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
|
||||
|
||||
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
|
||||
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
|
||||
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
|
||||
/// the restore path that assertion costs a monitor its refresh rate.
|
||||
fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
match crate::proc::status_within(
|
||||
std::process::Command::new("kscreen-doctor").args(args),
|
||||
KSCREEN_BUDGET,
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
|
||||
//! restore at that session's teardown and re-light the panel under a second, still-streaming
|
||||
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
|
||||
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
|
||||
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
|
||||
//! the *timing* of each release.
|
||||
//!
|
||||
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
|
||||
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
|
||||
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
|
||||
//! precisely because its disable survives everything.)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
|
||||
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
|
||||
// `wl_output`, which `wayland_client::protocol` already provides.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod protocol {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/dpms.xml");
|
||||
}
|
||||
|
||||
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
|
||||
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
|
||||
|
||||
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
|
||||
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
|
||||
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
|
||||
const DPMS_MODE_ON: u32 = 0;
|
||||
const DPMS_MODE_OFF: u32 = 3;
|
||||
|
||||
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
|
||||
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
|
||||
const MANAGER_MAX: u32 = 1;
|
||||
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
|
||||
/// works at v1, so a lower advert just costs the log its names.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
|
||||
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
|
||||
/// session-create (or group-teardown) thread.
|
||||
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
|
||||
const POLL_MS: i32 = 100;
|
||||
|
||||
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
|
||||
#[derive(Default)]
|
||||
struct OutputState {
|
||||
proxy: Option<WlOutput>,
|
||||
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
|
||||
/// the address everything operates on.
|
||||
connector: Option<String>,
|
||||
dpms: Option<Dpms>,
|
||||
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
|
||||
supported: Option<bool>,
|
||||
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
|
||||
/// flip.
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
/// Everything one connection's queue accumulates.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<DpmsManager>,
|
||||
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
|
||||
/// the identity the darken records so the re-light (a separate, later connection) can find the
|
||||
/// same outputs again.
|
||||
outputs: HashMap<u32, OutputState>,
|
||||
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||
sync_done: u32,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => {
|
||||
if interface == DpmsManager::interface().name {
|
||||
let v = version.min(MANAGER_MAX);
|
||||
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
|
||||
} else if interface == WlOutput::interface().name {
|
||||
let v = version.min(WL_OUTPUT_MAX);
|
||||
// The global name rides in the UserData so the output's own events (and the
|
||||
// dpms object's, which gets the same stamp) can find this entry.
|
||||
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
|
||||
state.outputs.entry(name).or_default().proxy = Some(out);
|
||||
}
|
||||
}
|
||||
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.remove(&name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.get_mut(global) {
|
||||
o.connector = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<Dpms, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &Dpms,
|
||||
event: DpmsEvent,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(o) = state.outputs.get_mut(global) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
|
||||
DpmsEvent::Mode { mode } => o.mode = Some(mode),
|
||||
DpmsEvent::Done => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
|
||||
impl Dispatch<DpmsManager, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &DpmsManager,
|
||||
_: protocol::org_kde_kwin_dpms_manager::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
serial: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
state.sync_done = state.sync_done.max(*serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Session::open`] declined — the same honest-decline discipline as
|
||||
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
|
||||
/// `kscreen-doctor` fallback is worth attempting.
|
||||
enum OpenFailure {
|
||||
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
|
||||
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
|
||||
Connect(String),
|
||||
/// The compositor accepted the connection but did not answer the registry barrier in budget:
|
||||
/// a live but wedged session — the case the shell-out fallback exists for.
|
||||
RegistryBarrier,
|
||||
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
|
||||
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
|
||||
/// KDE-only machinery), so this rung declines without one.
|
||||
NoDpmsGlobal,
|
||||
/// The manager is there but the per-output DPMS state bursts never completed in budget.
|
||||
StateBarrier,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpenFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
|
||||
OpenFailure::RegistryBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the compositor did not answer the registry roundtrip in budget"
|
||||
)
|
||||
}
|
||||
OpenFailure::NoDpmsGlobal => {
|
||||
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
|
||||
}
|
||||
OpenFailure::StateBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the outputs' DPMS state never finished announcing in budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected session with the manager bound and every output's DPMS state read.
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
queue: wayland_client::EventQueue<State>,
|
||||
state: State,
|
||||
next_sync: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
|
||||
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
|
||||
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
|
||||
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
|
||||
fn open(op: &'static str) -> Result<Session, OpenFailure> {
|
||||
let opened = Session::connect();
|
||||
if let Err(reason) = &opened {
|
||||
match reason {
|
||||
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
|
||||
tracing::debug!(op, %reason, "KWin DPMS unavailable");
|
||||
}
|
||||
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
|
||||
tracing::warn!(
|
||||
op,
|
||||
%reason,
|
||||
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
|
||||
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
|
||||
fn connect() -> Result<Session, OpenFailure> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
|
||||
let queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut s = Session {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
next_sync: 0,
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Phase 1: process the registry globals (binds the manager + every wl_output).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::RegistryBarrier);
|
||||
}
|
||||
let Some(mgr) = s.state.manager.clone() else {
|
||||
return Err(OpenFailure::NoDpmsGlobal);
|
||||
};
|
||||
// Phase 2: one dpms status object per output (stamped with the output's global name so its
|
||||
// events land on the right entry), then a barrier that drains both the outputs' `name`
|
||||
// events and the dpms objects' supported/mode/done bursts.
|
||||
let qh = s.queue.handle();
|
||||
let bound: Vec<(u32, WlOutput)> = s
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
|
||||
.collect();
|
||||
for (global, out) in bound {
|
||||
let d = mgr.get(&out, &qh, global);
|
||||
if let Some(o) = s.state.outputs.get_mut(&global) {
|
||||
o.dpms = Some(d);
|
||||
}
|
||||
}
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::StateBarrier);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||
self.next_sync += 1;
|
||||
let serial = self.next_sync;
|
||||
let qh = self.queue.handle();
|
||||
let _cb = self.conn.display().sync(&qh, serial);
|
||||
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||
}
|
||||
|
||||
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
|
||||
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
|
||||
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
|
||||
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||
loop {
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||
return false;
|
||||
}
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
if self.conn.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(guard) = self.conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
|
||||
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
|
||||
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
|
||||
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
|
||||
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
|
||||
// aliases nothing.
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||
}
|
||||
}
|
||||
|
||||
/// Request `target` on every DPMS-supporting output not already there — restricted to the
|
||||
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
|
||||
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
|
||||
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
|
||||
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
|
||||
/// request the compositor may decline, so the confirmation is watched and its absence logged
|
||||
/// rather than assumed.
|
||||
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
|
||||
for (global, o) in &self.state.outputs {
|
||||
if only.is_some_and(|list| !list.contains(global)) {
|
||||
continue;
|
||||
}
|
||||
if o.supported != Some(true) || o.mode == Some(target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dpms) = &o.dpms {
|
||||
dpms.set(target);
|
||||
touched.push((*global, o.connector.clone()));
|
||||
}
|
||||
}
|
||||
if touched.is_empty() {
|
||||
return touched;
|
||||
}
|
||||
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
|
||||
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
|
||||
// nothing left to flip.
|
||||
let confirmed = self.pump_until(deadline, |st| {
|
||||
want.iter()
|
||||
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
|
||||
});
|
||||
if !confirmed {
|
||||
tracing::warn!(
|
||||
outputs = ?touched,
|
||||
target,
|
||||
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
|
||||
requests are flushed; it may still land, or KWin may have declined)"
|
||||
);
|
||||
}
|
||||
touched
|
||||
}
|
||||
}
|
||||
|
||||
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
|
||||
/// work matters: the two are undone through different doors.
|
||||
enum Darkened {
|
||||
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
|
||||
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
|
||||
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
|
||||
/// no-op, because a fresh KWin brings its outputs up lit anyway.
|
||||
Wayland(Vec<(u32, Option<String>)>),
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
|
||||
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
|
||||
struct Holds {
|
||||
count: u32,
|
||||
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
|
||||
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
|
||||
/// nothing to undo, which is exactly right.
|
||||
darkened: Option<Darkened>,
|
||||
}
|
||||
|
||||
impl Holds {
|
||||
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
|
||||
fn acquire_edge(&mut self) -> bool {
|
||||
self.count += 1;
|
||||
self.count == 1
|
||||
}
|
||||
|
||||
/// Store the 0→1 darken's outcome.
|
||||
fn record(&mut self, d: Option<Darkened>) {
|
||||
self.darkened = d;
|
||||
}
|
||||
|
||||
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
|
||||
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
|
||||
fn release_edge(&mut self) -> Option<Darkened> {
|
||||
if self.count == 0 {
|
||||
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
|
||||
return None;
|
||||
}
|
||||
self.count -= 1;
|
||||
if self.count == 0 {
|
||||
self.darkened.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
});
|
||||
|
||||
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
|
||||
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
|
||||
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
|
||||
/// as the display's topology restore, so the registry runs it exactly once per display at
|
||||
/// teardown (§6.1).
|
||||
///
|
||||
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
|
||||
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
|
||||
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
|
||||
/// strictly ordered: re-light completes, then the new stream's darken runs.
|
||||
pub fn acquire_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if h.acquire_edge() {
|
||||
let d = darken();
|
||||
h.record(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
|
||||
pub fn release_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(d) = h.release_edge() {
|
||||
relight(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
let touched = s.set_mode(DPMS_MODE_OFF, None);
|
||||
if touched.is_empty() {
|
||||
tracing::debug!(
|
||||
"KWin DPMS: no output to darken (none supported, or all already off)"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
outputs = ?touched,
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
Some(Darkened::Wayland(touched))
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
Err(_) => match kscreen_dpms("off") {
|
||||
Some(true) => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
|
||||
(kscreen-doctor fallback)"
|
||||
);
|
||||
Some(Darkened::Kscreen)
|
||||
}
|
||||
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
|
||||
// on the compositor, so a loaded KWin routinely lands the change and still gets
|
||||
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
|
||||
// against a lit panel is a no-op.
|
||||
None => Some(Darkened::Kscreen),
|
||||
Some(false) => {
|
||||
tracing::warn!(
|
||||
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
|
||||
(in-process path and kscreen-doctor both declined) — the panel stays lit"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
|
||||
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
|
||||
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
|
||||
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
|
||||
fn relight(d: Darkened) {
|
||||
match d {
|
||||
Darkened::Wayland(outputs) => {
|
||||
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
|
||||
match Session::open("re-light") {
|
||||
Ok(mut s) => {
|
||||
s.set_mode(DPMS_MODE_ON, Some(&globals));
|
||||
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
|
||||
}
|
||||
Err(_) => match kscreen_dpms("on") {
|
||||
Some(true) | None => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
|
||||
);
|
||||
}
|
||||
Some(false) => {
|
||||
tracing::error!(
|
||||
outputs = ?outputs,
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
|
||||
restore and kscreen-doctor both declined) — the panel stays dark \
|
||||
until local input wakes it"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Darkened::Kscreen => {
|
||||
if kscreen_dpms("on") == Some(false) {
|
||||
tracing::error!(
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
|
||||
the --dpms on it earlier accepted the off for) — the panel stays dark until \
|
||||
local input wakes it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
|
||||
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
|
||||
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
|
||||
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
|
||||
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Darkened, Holds};
|
||||
|
||||
fn fresh() -> Holds {
|
||||
Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_acquire_darkens_later_ones_count() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge(), "0→1 must darken");
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(
|
||||
!h.acquire_edge(),
|
||||
"a second concurrent stream must not re-darken"
|
||||
);
|
||||
assert!(!h.acquire_edge());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_release_relights() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
|
||||
assert!(!h.acquire_edge());
|
||||
// First release: a sibling still streams — the panel must stay dark.
|
||||
assert!(h.release_edge().is_none());
|
||||
// Last release hands back the record to undo.
|
||||
let d = h.release_edge();
|
||||
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_darken_that_did_nothing_restores_nothing() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(None); // no KDE / already dark: nothing was changed
|
||||
assert!(h.release_edge().is_none(), "nothing to undo");
|
||||
assert_eq!(h.count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_release_never_underflows() {
|
||||
let mut h = fresh();
|
||||
assert!(h.release_edge().is_none());
|
||||
assert_eq!(h.count, 0, "count must not wrap");
|
||||
// And the state machine still works afterwards.
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cycle_rearms_the_darken() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(h.release_edge().is_some());
|
||||
// A later stream on the same host lifetime darkens again.
|
||||
assert!(
|
||||
h.acquire_edge(),
|
||||
"the 0→1 edge must re-arm after a full cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend
|
||||
//! advertises, rather than asserted.
|
||||
//!
|
||||
//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from
|
||||
//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call**
|
||||
//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the
|
||||
//! request against the backend's advertised bitfield before the backend ever sees it). Both
|
||||
//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor
|
||||
//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4`
|
||||
//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed".
|
||||
//! Field report 2026-08-14.
|
||||
//!
|
||||
//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on
|
||||
//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**,
|
||||
//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init
|
||||
//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl
|
||||
//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same
|
||||
//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke
|
||||
//! every cursor-forward session on the entire wlr family, on current software — not only on old
|
||||
//! installs. (xdph 1.4.1 would itself fall back — its binary carries
|
||||
//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance,
|
||||
//! because the frontend fails the call first.)
|
||||
//!
|
||||
//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is
|
||||
//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not
|
||||
//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the
|
||||
//! orchestrator"), so the two copies are deliberate; keep the ladders in step.
|
||||
//!
|
||||
//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer
|
||||
//! work, and its tests are the whole point of the module — this is a decision that leaves no trace
|
||||
//! anyone can check without a compositor in front of them — so they run on every platform's CI
|
||||
//! rather than on the one leg that compiles `mod hyprland`.
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
Embedded = 2,
|
||||
/// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its
|
||||
/// cheap hardware cursor plane, and the consumer either composites the shape itself or
|
||||
/// forwards it to a client that draws its own.
|
||||
Metadata = 4,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The portal's bit for this mode.
|
||||
pub(crate) const fn bit(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
Mode::Metadata => "metadata",
|
||||
}
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
// The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a
|
||||
// pointer on the client's screen (the compositor's, burnt in) — and because no
|
||||
// `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a
|
||||
// cursor-forward client draws nothing of its own, so this is one pointer, not two.
|
||||
// `Hidden` is last: it streams a desktop nobody can point at.
|
||||
Mode::Metadata => [Mode::Embedded, Mode::Hidden],
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path
|
||||
// composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
Mode::Embedded => [Mode::Metadata, Mode::Hidden],
|
||||
// A deliberate request for no pointer that the backend will not honour. Either
|
||||
// remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on
|
||||
// this path is set up to draw.
|
||||
Mode::Hidden => [Mode::Embedded, Mode::Metadata],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the ladder: what to request, and what the session actually wanted if those
|
||||
/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Choice {
|
||||
/// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing.
|
||||
pub(crate) mode: Mode,
|
||||
/// Set only when `mode` is a downgrade: the mode the session asked for and could not have.
|
||||
pub(crate) wanted: Option<Mode>,
|
||||
}
|
||||
|
||||
/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield.
|
||||
///
|
||||
/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail
|
||||
/// comment, which is the one case with no right answer.
|
||||
pub(crate) fn pick(advertised: u32, want: Mode) -> Choice {
|
||||
if advertised & want.bit() != 0 {
|
||||
return Choice {
|
||||
mode: want,
|
||||
wanted: None,
|
||||
};
|
||||
}
|
||||
for alt in want.fallbacks() {
|
||||
if advertised & alt.bit() != 0 {
|
||||
return Choice {
|
||||
mode: alt,
|
||||
wanted: Some(want),
|
||||
};
|
||||
}
|
||||
}
|
||||
// The backend advertised no mode this build knows — 0, or only bits from a spec revision newer
|
||||
// than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is
|
||||
// both the most universally implemented and the only one that cannot end up drawing two
|
||||
// pointers. The caller warns: whatever this backend is doing, we are guessing.
|
||||
Choice {
|
||||
mode: Mode::Hidden,
|
||||
wanted: Some(want),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pin {
|
||||
/// Unset or `auto` — the session's own negotiation decides.
|
||||
Auto,
|
||||
/// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin
|
||||
/// can never re-create the session-killing request this module exists to prevent.
|
||||
Mode(Mode),
|
||||
/// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud —
|
||||
/// a typo'd escape hatch that silently does nothing is worse than no escape hatch.
|
||||
Unrecognised,
|
||||
}
|
||||
|
||||
/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value.
|
||||
pub(crate) fn parse_pin(raw: &str) -> Pin {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" => Pin::Auto,
|
||||
"hidden" | "none" => Pin::Mode(Mode::Hidden),
|
||||
"embedded" | "composited" => Pin::Mode(Mode::Embedded),
|
||||
"metadata" | "meta" => Pin::Mode(Mode::Metadata),
|
||||
_ => Pin::Unrecognised,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel
|
||||
/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn
|
||||
/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both.
|
||||
///
|
||||
/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`).
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
let negotiated = if hw_cursor {
|
||||
Mode::Metadata
|
||||
} else {
|
||||
Mode::Embedded
|
||||
};
|
||||
let raw = match pf_host_config::config().portal_cursor_mode.as_deref() {
|
||||
Some(raw) => raw,
|
||||
None => return negotiated,
|
||||
};
|
||||
match parse_pin(raw) {
|
||||
Pin::Auto => negotiated,
|
||||
Pin::Mode(pinned) => {
|
||||
tracing::info!(
|
||||
backend,
|
||||
pinned = pinned.name(),
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE"
|
||||
);
|
||||
pinned
|
||||
}
|
||||
Pin::Unrecognised => {
|
||||
tracing::warn!(
|
||||
backend,
|
||||
value = raw,
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\
|
||||
metadata) — ignoring"
|
||||
);
|
||||
negotiated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
Mode::Embedded => CursorMode::Embedded,
|
||||
Mode::Metadata => CursorMode::Metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
Err(e) => {
|
||||
// `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to
|
||||
// publish it is also too old to have metadata, and `Embedded` is what this backend
|
||||
// requested for its whole life before the cursor channel existed.
|
||||
tracing::warn!(
|
||||
backend,
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
match choice.wanted {
|
||||
None => tracing::info!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: cursor mode negotiated"
|
||||
),
|
||||
// The downgrade path — and the one that used to be a dead session. Loud, because a stream
|
||||
// whose pointer quietly changed hands is exactly what nobody thinks to check.
|
||||
Some(wanted) => tracing::warn!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
wanted = wanted.name(),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: requested cursor mode is not advertised by this portal — downgrading \
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The portal's wire values. These are ABI — a backend rejecting our request prints the
|
||||
/// number, and `4` is the one in the field report that started this module.
|
||||
#[test]
|
||||
fn mode_bits_are_the_portal_wire_values() {
|
||||
assert_eq!(Mode::Hidden.bit(), 1);
|
||||
assert_eq!(Mode::Embedded.bit(), 2);
|
||||
assert_eq!(Mode::Metadata.bit(), 4);
|
||||
}
|
||||
|
||||
/// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from
|
||||
/// declaration order — so a reordering upstream would silently repoint every mode. Pin it
|
||||
/// where ashpd is actually compiled.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn mode_bits_match_ashpd() {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] {
|
||||
assert_eq!(
|
||||
BitFlags::from_flag(m.to_ashpd()).bits(),
|
||||
m.bit(),
|
||||
"{} drifted from ashpd",
|
||||
m.name()
|
||||
);
|
||||
}
|
||||
assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4);
|
||||
}
|
||||
|
||||
/// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on
|
||||
/// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward
|
||||
/// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client
|
||||
/// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4".
|
||||
#[test]
|
||||
fn metadata_wanted_but_unadvertised_downgrades_to_embedded() {
|
||||
// Exactly the bitfield the portal reported on glass.
|
||||
assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3);
|
||||
let c = pick(3, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
fn embedded_wanted_and_advertised_is_untouched() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point
|
||||
/// is to stop asserting, not to stop using it.
|
||||
#[test]
|
||||
fn metadata_is_used_where_advertised() {
|
||||
let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit();
|
||||
let c = pick(all, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer
|
||||
/// survives. (Mirrors `pf-capture`'s ladder.)
|
||||
#[test]
|
||||
fn embedded_unadvertised_falls_to_metadata_not_hidden() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, Some(Mode::Embedded));
|
||||
}
|
||||
|
||||
/// A backend offering only `Hidden`: a cursorless stream beats a closed session.
|
||||
#[test]
|
||||
fn hidden_only_backend_yields_hidden() {
|
||||
let c = pick(Mode::Hidden.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged
|
||||
/// as a downgrade so the warn fires.
|
||||
#[test]
|
||||
fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() {
|
||||
for advertised in [0, 0b1000_0000] {
|
||||
let c = pick(advertised, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whatever the ladder returns must be a mode the backend named — the invariant the old
|
||||
/// hardcode broke. Exhaustive over every advertisement × every want.
|
||||
#[test]
|
||||
fn never_requests_an_unadvertised_mode() {
|
||||
let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata];
|
||||
for advertised in 1u32..=0b111 {
|
||||
for want in modes {
|
||||
let c = pick(advertised, want);
|
||||
assert!(
|
||||
advertised & c.mode.bit() != 0,
|
||||
"picked {} from advertised {advertised:#05b} (want {})",
|
||||
c.mode.name(),
|
||||
want.name()
|
||||
);
|
||||
// A downgrade is reported exactly when one happened.
|
||||
assert_eq!(c.wanted.is_some(), c.mode != want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_parses_the_spellings_we_document() {
|
||||
assert_eq!(parse_pin(""), Pin::Auto);
|
||||
assert_eq!(parse_pin("auto"), Pin::Auto);
|
||||
assert_eq!(parse_pin(" AUTO "), Pin::Auto);
|
||||
assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata));
|
||||
assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden));
|
||||
assert_eq!(parse_pin("2"), Pin::Unrecognised);
|
||||
assert_eq!(parse_pin("yes"), Pin::Unrecognised);
|
||||
}
|
||||
|
||||
/// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must
|
||||
/// still come out embedded rather than re-closing the session.
|
||||
#[test]
|
||||
fn a_pin_still_runs_the_ladder() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,17 @@ fn chooser_cmd() -> String {
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct WlrootsDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually
|
||||
/// advertises, because requesting an unadvertised mode closes the session outright. xdpw
|
||||
/// refuses metadata by construction (see the portal thread), so on this backend the channel can
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -512,13 +517,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -542,6 +541,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// 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 —
|
||||
// if (sess->screencast_data.cursor_mode & METADATA) {
|
||||
// logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling");
|
||||
// — 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
|
||||
|
||||
@@ -17,7 +17,19 @@ parse_deps = false
|
||||
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
|
||||
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
|
||||
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
|
||||
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
#
|
||||
# `SOFT_LIMIT_KNEE` is host-side CAPTURE processing (the operator gain's soft knee, applied before
|
||||
# the encoder). No C embedder can act on it — they receive already-gained audio — so exporting it
|
||||
# would add a bare `#define` to the ABI surface, against R21 below, for a constant with no meaning
|
||||
# on that side of the boundary. Excluded rather than renamed: the header stays byte-identical.
|
||||
exclude = [
|
||||
"MsghdrX",
|
||||
"recvmsg_x",
|
||||
"mmsghdr",
|
||||
"sendmmsg",
|
||||
"recvmmsg",
|
||||
"SOFT_LIMIT_KNEE",
|
||||
]
|
||||
# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the
|
||||
# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately,
|
||||
# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave
|
||||
|
||||
@@ -955,6 +955,68 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
|
||||
ring.drain(..drop);
|
||||
}
|
||||
|
||||
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ −3.1 dBFS). Below this the
|
||||
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
|
||||
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
|
||||
/// it.
|
||||
pub const SOFT_LIMIT_KNEE: f32 = 0.7;
|
||||
|
||||
/// Multiply `samples` by `gain`, bending anything that would overshoot full scale into a soft knee
|
||||
/// instead of slicing it flat.
|
||||
///
|
||||
/// **Why this is not a `clamp`.** The GameStream plane's gain was `(s * gain).clamp(-1.0, 1.0)`,
|
||||
/// which is a hard clip: the waveform's peaks are replaced by literal flat tops, and a flat top is
|
||||
/// a discontinuity in the first derivative. That radiates high-order harmonics — the harsher and
|
||||
/// more aliasing-prone the higher they go — which is why a field report of "+18 dB and everything
|
||||
/// warbles" is the expected outcome of that code and not a bug in anything downstream. Any operator
|
||||
/// who set `PUNKTFUNK_AUDIO_GAIN` much above ~1.5 was hearing this.
|
||||
///
|
||||
/// The curve here is `tanh`-based and chosen for three properties, in this order:
|
||||
///
|
||||
/// 1. **C¹-continuous at the knee.** The shaped branch's slope at `m == KNEE` is
|
||||
/// `(1-K) · sech²(0) · 1/(1-K) == 1`, exactly the slope of the linear branch it meets. There is
|
||||
/// no corner in the transfer curve, so the onset of limiting is not itself an audible event —
|
||||
/// the failure mode of a naïve piecewise limiter, which trades one discontinuity for another.
|
||||
/// 2. **Bounded by construction.** `tanh` is asymptotic to 1, so the output approaches but never
|
||||
/// exceeds full scale for any finite input, and `±inf` maps to `±1.0`. No sample can leave here
|
||||
/// out of range, which is what the encoder downstream assumes.
|
||||
/// 3. **Odd-symmetric.** `f(-x) == -f(x)`, so the distortion it does introduce is odd-harmonic and
|
||||
/// adds no DC offset — the benign, "saturating" flavour rather than the rectifying one.
|
||||
///
|
||||
/// Callers gate on `gain != 1.0`, so the default path is untouched and the wire stays byte-for-byte
|
||||
/// identical to a build without this. Note this is a WAVESHAPER, not a lookahead limiter: it is
|
||||
/// memoryless and therefore costs zero latency, which is the trade that makes it acceptable in the
|
||||
/// realtime encode path. It raises headroom; it does not raise *loudness* the way a compressor
|
||||
/// with a real time constant would, and it should not be sold as one.
|
||||
pub fn apply_gain(samples: &mut [f32], gain: f32) {
|
||||
// Unity is a no-op, not "multiply by one and shape": the shaper is only correct to apply to a
|
||||
// signal somebody asked to boost. Without this, calling at unity would bend every peak above
|
||||
// the knee — a silent quality change for anyone who forgot to gate the call, and the reason
|
||||
// the callers' `gain != 1.0` guards are a convenience rather than a load-bearing contract.
|
||||
if gain == 1.0 {
|
||||
return;
|
||||
}
|
||||
for s in samples {
|
||||
*s = soft_limit(*s * gain);
|
||||
}
|
||||
}
|
||||
|
||||
/// The waveshaper behind [`apply_gain`]: identity below [`SOFT_LIMIT_KNEE`], asymptotic to ±1.0
|
||||
/// above it. Exposed so the clients can mirror the curve if they ever grow a gain of their own.
|
||||
pub fn soft_limit(x: f32) -> f32 {
|
||||
let m = x.abs();
|
||||
if m <= SOFT_LIMIT_KNEE {
|
||||
return x;
|
||||
}
|
||||
let head = 1.0 - SOFT_LIMIT_KNEE;
|
||||
let shaped = SOFT_LIMIT_KNEE + head * ((m - SOFT_LIMIT_KNEE) / head).tanh();
|
||||
if x < 0.0 {
|
||||
-shaped
|
||||
} else {
|
||||
shaped
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
|
||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||
@@ -2432,4 +2494,77 @@ mod tests {
|
||||
assert!(s.audible_tail <= 4, "{s:?}");
|
||||
assert!(s.audible <= 12, "{s:?}");
|
||||
}
|
||||
|
||||
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
|
||||
/// holding, every default session's wire would shift and the "byte-for-byte identical" claim
|
||||
/// the tier machinery rests on would quietly become false.
|
||||
#[test]
|
||||
fn unity_gain_is_bit_exact() {
|
||||
let src: Vec<f32> = (0..512).map(|i| (i as f32 / 512.0) * 2.0 - 1.0).collect();
|
||||
let mut got = src.clone();
|
||||
apply_gain(&mut got, 1.0);
|
||||
assert_eq!(got, src, "unity gain must not touch a single sample");
|
||||
}
|
||||
|
||||
/// Below the knee the limiter is not in circuit at all: a boost whose peaks stay under
|
||||
/// `SOFT_LIMIT_KNEE` must be plain multiplication, or quiet material pays for a limiter it
|
||||
/// never needed.
|
||||
#[test]
|
||||
fn below_the_knee_is_plain_multiplication() {
|
||||
let mut got = vec![0.0, 0.1, -0.2, 0.34, -0.05];
|
||||
apply_gain(&mut got, 2.0);
|
||||
for (i, (g, s)) in got.iter().zip([0.0f32, 0.1, -0.2, 0.34, -0.05]).enumerate() {
|
||||
assert_eq!(*g, s * 2.0, "sample {i} must be untouched below the knee");
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the hard `clamp` violated and this exists to restore: no input, however
|
||||
/// absurdly gained, may leave the shaper out of range — and non-finite input must not escape
|
||||
/// as something the encoder would choke on.
|
||||
#[test]
|
||||
fn nothing_escapes_full_scale() {
|
||||
for gain in [1.5f32, 4.0, 8.0, 64.0, 1000.0] {
|
||||
let mut got: Vec<f32> = (0..401).map(|i| (i as f32 - 200.0) / 200.0).collect();
|
||||
apply_gain(&mut got, gain);
|
||||
for s in &got {
|
||||
assert!(s.abs() <= 1.0, "gain {gain} produced {s}");
|
||||
}
|
||||
}
|
||||
assert_eq!(soft_limit(f32::INFINITY), 1.0);
|
||||
assert_eq!(soft_limit(f32::NEG_INFINITY), -1.0);
|
||||
}
|
||||
|
||||
/// Monotonic and odd-symmetric. Monotonicity is what keeps the shaper a limiter rather than a
|
||||
/// fold-back distortion; odd symmetry is what keeps its harmonics benign and its DC at zero.
|
||||
#[test]
|
||||
fn the_curve_is_monotonic_and_odd() {
|
||||
let mut prev = f32::NEG_INFINITY;
|
||||
for i in 0..=4000 {
|
||||
let x = (i as f32 - 2000.0) / 500.0; // -4.0 ..= 4.0
|
||||
let y = soft_limit(x);
|
||||
assert!(y >= prev, "not monotonic at {x}: {y} < {prev}");
|
||||
prev = y;
|
||||
assert!(
|
||||
(soft_limit(-x) + y).abs() < 1e-6,
|
||||
"not odd-symmetric at {x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The knee must not itself be an audible event. Both branches meet at the same value AND the
|
||||
/// same slope, so the transfer curve has no corner — a piecewise limiter that gets this wrong
|
||||
/// just swaps the clip's discontinuity for a softer one.
|
||||
#[test]
|
||||
fn the_knee_has_no_corner() {
|
||||
let k = SOFT_LIMIT_KNEE;
|
||||
assert!((soft_limit(k) - k).abs() < 1e-6, "value jumps at the knee");
|
||||
let h = 1e-4;
|
||||
let below = (soft_limit(k) - soft_limit(k - h)) / h;
|
||||
let above = (soft_limit(k + h) - soft_limit(k)) / h;
|
||||
assert!((below - 1.0).abs() < 1e-2, "linear side slope {below}");
|
||||
assert!(
|
||||
(above - below).abs() < 1e-2,
|
||||
"slope jumps at the knee: {below} -> {above}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,54 @@ pub const SAMPLE_RATE: u32 = 48_000;
|
||||
/// Stereo channel count — the default and the punktfunk/1 audio plane's fixed layout.
|
||||
pub const CHANNELS: usize = 2;
|
||||
|
||||
/// Highest boost `PUNKTFUNK_AUDIO_GAIN` will honour (+18 dB). Past this the soft knee is doing
|
||||
/// essentially all the work and the result is a squashed signal, not a louder one — so a runaway
|
||||
/// value (a stray `180` for `1.8`) is capped and said out loud rather than silently shipped.
|
||||
const MAX_CAPTURE_GAIN: f32 = 8.0;
|
||||
|
||||
/// The operator's capture gain, shared by BOTH audio planes (`PUNKTFUNK_AUDIO_GAIN`, default
|
||||
/// `1.0` = untouched).
|
||||
///
|
||||
/// **Why the host needs one at all.** WASAPI loopback is tapped UPSTREAM of the endpoint's master
|
||||
/// volume, so turning the host's speaker slider up does nothing whatsoever to the level a client
|
||||
/// receives. Before this, the native `punktfunk/1` plane had no gain of any kind, which left no
|
||||
/// host-side way to raise a quiet desktop mix — the GameStream plane's knob was the only one, and
|
||||
/// it applied to the wrong protocol.
|
||||
///
|
||||
/// Applied through [`punktfunk_core::audio::apply_gain`], whose soft knee replaces the hard
|
||||
/// `clamp(-1.0, 1.0)` this used to be. That clamp is why boosting was a trap: it flat-tops peaks,
|
||||
/// and flat tops are audible as harsh distortion long before the operator reaches the level they
|
||||
/// were chasing.
|
||||
///
|
||||
/// ⚠ This is headroom, not loudness. It cannot close a peak-to-loudness gap against
|
||||
/// already-limited broadcast content — that needs a real compressor with a time constant, which is
|
||||
/// deliberately NOT what this is.
|
||||
pub fn capture_gain() -> f32 {
|
||||
let raw: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// A negative or non-finite gain is a typo, never an intent: it would invert or poison every
|
||||
// sample. Fall back to unity rather than shipping it.
|
||||
if !raw.is_finite() || raw <= 0.0 {
|
||||
if std::env::var("PUNKTFUNK_AUDIO_GAIN").is_ok() {
|
||||
tracing::warn!(
|
||||
"PUNKTFUNK_AUDIO_GAIN must be a positive number (1.0 = unchanged) — ignoring"
|
||||
);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
if raw > MAX_CAPTURE_GAIN {
|
||||
tracing::warn!(
|
||||
requested = raw,
|
||||
capped = MAX_CAPTURE_GAIN,
|
||||
"PUNKTFUNK_AUDIO_GAIN is above the +18 dB ceiling — capping"
|
||||
);
|
||||
return MAX_CAPTURE_GAIN;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
/// Produces interleaved `f32` PCM at [`SAMPLE_RATE`] in the channel count it was opened
|
||||
/// with. Lives on its own thread; never blocks the capture loop (drops if the consumer
|
||||
/// falls behind).
|
||||
|
||||
@@ -26,13 +26,22 @@
|
||||
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
|
||||
//! Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//! record the client's mic by default — applied, like the playback default, ONLY while a
|
||||
//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot
|
||||
//! included, which left an IDLE box's default recording/communication device parked on a virtual
|
||||
//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint`
|
||||
//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports
|
||||
//! measured that as 1% lows of 2–5 FPS in a LOCALLY played game while the host sat idle (HD2 is
|
||||
//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns
|
||||
//! about). An idle host must leave the box's audio defaults exactly as the operator set them.
|
||||
//!
|
||||
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
|
||||
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
|
||||
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
|
||||
//! operator must never be stranded with silent speakers. A default the operator changed themselves
|
||||
//! mid-stream is respected (no restore over their choice).
|
||||
//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the
|
||||
//! virtual mic — the operator's devices are remembered ([`park_default_playback`] /
|
||||
//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes
|
||||
//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next
|
||||
//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead
|
||||
//! mic. A default the operator changed themselves mid-stream is respected (no restore over their
|
||||
//! choice).
|
||||
//!
|
||||
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
|
||||
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
|
||||
@@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 {
|
||||
}
|
||||
|
||||
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
pub(crate) fn wire_now(park_defaults: bool) -> Wiring {
|
||||
wire_now_full(park_defaults).wiring
|
||||
}
|
||||
|
||||
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
|
||||
@@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
|
||||
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
|
||||
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
|
||||
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
|
||||
/// state.
|
||||
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING
|
||||
/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes
|
||||
/// false: it runs while the host is idle and must neither silence the box nor hold its default
|
||||
/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the
|
||||
/// module docs). Must run on a COM-initialized thread (the WASAPI worker threads all
|
||||
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
|
||||
/// stays quiet in the steady state.
|
||||
pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
|
||||
recover_orphaned_default();
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
@@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// Mix formats are read only when we are actually going to park the defaults (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
// activation per render endpoint on every pass.
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if park_defaults {
|
||||
&mix_format_of
|
||||
} else {
|
||||
&wiring_plan::no_formats
|
||||
@@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set_playback {
|
||||
// Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default
|
||||
// recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to
|
||||
// restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds
|
||||
// until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below
|
||||
// can't heal it either: it remembers a previous default only when the default isn't already
|
||||
// ours). While nothing is parked, a default found sitting on the plan's mic capture moves to
|
||||
// the first real microphone. Session passes own the default and are exempt; a box with no
|
||||
// real microphone is left alone.
|
||||
if !park_defaults && PARKED_REC.lock().unwrap().is_none() {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_capture {
|
||||
if default_capture_id().as_deref() == Some(mic_id.as_str()) {
|
||||
if let Some((name, id)) =
|
||||
wiring_plan::real_capture(&captures, Some(mic_id.as_str()))
|
||||
{
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(from = %mic_name, device = %name,
|
||||
"default recording was left on the virtual mic outside a stream — \
|
||||
moved it back to a real microphone"),
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"failed to move the default recording off the virtual mic"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if park_defaults {
|
||||
if let Some((name, id)) = &wiring.loopback_render {
|
||||
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
|
||||
park_default_playback(name, id, changed, mic_id);
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
|
||||
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
|
||||
// device-graph notification, each). Re-asserting on every wiring pass therefore both
|
||||
// churned the policy store AND silently stomped an operator's own recording-device
|
||||
// choice within one reopen cycle — write only when the plan changed or the default
|
||||
// actually drifted off the target.
|
||||
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
// The recording default is SESSION-SCOPED like the playback default, and for the same
|
||||
// reason inverted: parking it while idle handed the box's default microphone (and, via
|
||||
// eCommunications, every game's voice input) to a virtual mic nothing feeds — the
|
||||
// 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the
|
||||
// stream still binds the client's mic (this runs before the session's game does);
|
||||
// one launched before the stream keeps the operator's mic, which is the honest answer.
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
park_default_recording(name, id, changed);
|
||||
}
|
||||
}
|
||||
done(wiring)
|
||||
@@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The operator's default recording endpoint while we have it parked on the virtual mic:
|
||||
/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`].
|
||||
static PARKED_REC: Mutex<Option<(String, String)>> = Mutex::new(None);
|
||||
|
||||
/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id).
|
||||
fn rec_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default-rec.prev")
|
||||
}
|
||||
|
||||
/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the
|
||||
/// current default still is the endpoint we set — a default the operator changed since wins, like
|
||||
/// on every other restore path. The file is removed either way (it describes a park that is over).
|
||||
fn take_marker(path: &std::path::Path, current_default: Option<String>) -> Option<String> {
|
||||
let s = std::fs::read_to_string(path).ok()?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut lines = s.lines();
|
||||
let (prev, set) = (lines.next()?, lines.next()?);
|
||||
(current_default.as_deref() == Some(set)).then(|| prev.to_string())
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
@@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while the
|
||||
/// playback default was parked — put the operator's device back, but only if the default still
|
||||
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
|
||||
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while a default
|
||||
/// (playback and/or recording) was parked — put the operator's device back, but only if the
|
||||
/// default still IS the endpoint we set (a manual change since the crash wins). Runs on the first
|
||||
/// wiring pass (the mic pump wires eagerly at host start, so this fires at boot, not at the first
|
||||
/// stream).
|
||||
fn recover_orphaned_default() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return;
|
||||
};
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default playback device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default playback device left by a previous run"),
|
||||
for (path, current, what) in [
|
||||
(park_marker_path(), default_render_id(), "playback"),
|
||||
(rec_marker_path(), default_capture_id(), "recording"),
|
||||
] {
|
||||
let Some(prev) = take_marker(&path, current) else {
|
||||
continue;
|
||||
};
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default {what} device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default {what} device left by a previous run"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -415,20 +457,18 @@ fn recover_orphaned_default() {
|
||||
///
|
||||
/// Returns whether a device was actually put back — the caller only logs it.
|
||||
pub(crate) fn unpark_default_for_uninstall() -> bool {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return false;
|
||||
};
|
||||
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return false;
|
||||
let mut restored = false;
|
||||
for (path, current) in [
|
||||
(park_marker_path(), default_render_id()),
|
||||
(rec_marker_path(), default_capture_id()),
|
||||
] {
|
||||
// A default the operator changed by hand since the park wins, exactly as on the
|
||||
// recovery path (`take_marker` answers None then).
|
||||
if let Some(prev) = take_marker(&path, current) {
|
||||
restored |= set_default_endpoint(&prev).is_ok();
|
||||
}
|
||||
}
|
||||
set_default_endpoint(prev).is_ok()
|
||||
restored
|
||||
}
|
||||
|
||||
/// Make `id` the default playback device for the duration of the desktop-audio capture,
|
||||
@@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `id` the default recording device for the duration of the desktop-audio capture —
|
||||
/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in
|
||||
/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back.
|
||||
/// Nothing is remembered when `id` already is the default — there is nothing to restore.
|
||||
fn park_default_recording(name: &str, id: &str, changed: bool) {
|
||||
let cur = default_capture_id();
|
||||
if cur.as_deref() != Some(id) {
|
||||
let mut parked = PARKED_REC.lock().unwrap();
|
||||
match parked.as_mut() {
|
||||
None => {
|
||||
if let Some(prev) = cur.clone() {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*parked = Some((prev, id.to_string()));
|
||||
}
|
||||
}
|
||||
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
|
||||
// previous default, update what we set.
|
||||
Some((prev, set)) if set != id => {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*set = id.to_string();
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires
|
||||
// SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph
|
||||
// notification, each) — write only when the plan changed or the default actually drifted
|
||||
// off the target, or the policy store churns on every reopen.
|
||||
if changed || cur.as_deref() != Some(id) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
|
||||
/// wiring pass (WP2.4).
|
||||
///
|
||||
@@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the operator's default recording device back after streaming — the inverse of
|
||||
/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never
|
||||
/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on
|
||||
/// a COM-initialized thread (called from the capture thread's exit path).
|
||||
pub(crate) fn restore_default_recording() {
|
||||
let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(rec_marker_path());
|
||||
if default_capture_id().as_deref() != Some(set.as_str()) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!("default recording device restored after streaming"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default recording device after streaming"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
|
||||
@@ -518,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. ---
|
||||
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
|
||||
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called;
|
||||
/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are
|
||||
/// placeholders so the slot offsets are correct.
|
||||
#[repr(C)]
|
||||
struct IPolicyConfigVtbl {
|
||||
query_interface: unsafe extern "system" fn(
|
||||
@@ -537,7 +639,11 @@ struct IPolicyConfigVtbl {
|
||||
windows::core::PCWSTR,
|
||||
u32,
|
||||
) -> windows::core::HRESULT,
|
||||
// SetEndpointVisibility follows — unused.
|
||||
set_endpoint_visibility: unsafe extern "system" fn(
|
||||
*mut c_void,
|
||||
windows::core::PCWSTR,
|
||||
i32,
|
||||
) -> windows::core::HRESULT,
|
||||
}
|
||||
|
||||
// This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header
|
||||
@@ -546,18 +652,21 @@ struct IPolicyConfigVtbl {
|
||||
// table" — so a field added, removed or resized above it does not fail to compile: it silently calls
|
||||
// a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather
|
||||
// than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots
|
||||
// carry no names to anchor a review. These assertions pin the two things the call actually depends
|
||||
// on: the slot index of `set_default_endpoint`, and the size of the table up to it.
|
||||
// carry no names to anchor a review. These assertions pin the things the calls actually depend
|
||||
// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of
|
||||
// the table up to them.
|
||||
const _: () = {
|
||||
use std::mem::{offset_of, size_of};
|
||||
type P = *const c_void;
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based).
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based),
|
||||
// `set_endpoint_visibility` the slot after.
|
||||
assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0);
|
||||
assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 14 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 15 * size_of::<P>());
|
||||
};
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
@@ -603,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` —
|
||||
/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to
|
||||
/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but
|
||||
/// its devnode, driver binding and stamped identity all stay put — showing it again is instant
|
||||
/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless
|
||||
/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes
|
||||
/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services —
|
||||
/// the 2026-08-14 Helldivers 2 field confirmation).
|
||||
pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
|
||||
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
|
||||
|
||||
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance,
|
||||
// QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with
|
||||
// a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return.
|
||||
unsafe {
|
||||
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
|
||||
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
|
||||
let mut raw: *mut c_void = std::ptr::null_mut();
|
||||
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
|
||||
.ok()
|
||||
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
|
||||
if raw.is_null() {
|
||||
bail!("IPolicyConfig QueryInterface returned null");
|
||||
}
|
||||
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
|
||||
let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32);
|
||||
((*vtbl).release)(raw);
|
||||
hr.ok()
|
||||
.map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ pub(crate) struct Removed {
|
||||
pub endpoint_records: usize,
|
||||
}
|
||||
|
||||
/// Restore the default playback device if we left it parked, then remove every audio devnode
|
||||
/// this product minted, newest registry record and all.
|
||||
/// Restore the default playback/recording devices if we left them parked, then remove every
|
||||
/// audio devnode this product minted, newest registry record and all.
|
||||
///
|
||||
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
|
||||
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result<Removed> {
|
||||
// what the operator had. Putting it back is the difference between "the box works again"
|
||||
// and "the box works again, on the device it started with".
|
||||
if audio_control::unpark_default_for_uninstall() {
|
||||
println!("restored the default playback device this host had parked");
|
||||
println!("restored the default audio device(s) this host had parked");
|
||||
}
|
||||
|
||||
let mut out = Removed::default();
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
//! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]).
|
||||
//! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32
|
||||
//! interleaved) and ship the PCM to the client's pad speaker/haptics.
|
||||
//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`)
|
||||
//! whenever no client pad is attached — provisioning hides it at startup, the per-pad
|
||||
//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games
|
||||
//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise
|
||||
//! (Helldivers 2, field-confirmed 2026-08-14: 2–5 FPS 1% lows with the host idle). The
|
||||
//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic.
|
||||
//!
|
||||
//! The wiring plan must never route desktop audio or the virtual mic onto these endpoints —
|
||||
//! [`audio_control`](super::audio_control) collects the exclusion ids via
|
||||
@@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if !pad_audio_enabled() {
|
||||
tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)");
|
||||
// Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle
|
||||
// pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]).
|
||||
// Turning the feature off must also park the leftovers.
|
||||
hide_leftover_endpoints();
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() {
|
||||
@@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() {
|
||||
stored-but-not-served until the next reboot"),
|
||||
}
|
||||
}
|
||||
// Park every provisioned endpoint HIDDEN until a client pad actually attaches. The
|
||||
// expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot —
|
||||
// the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not
|
||||
// sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed
|
||||
// 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their
|
||||
// DualSense-haptics path against it, and stall on an endpoint nothing services —
|
||||
// 1% lows of 2–5 FPS with the host completely idle. The per-pad streamer shows it
|
||||
// for exactly the pad's lifetime, like a real DualSense arriving.
|
||||
for pe in &eps {
|
||||
set_visibility(&pe.endpoint_id, pe.pad_index, false);
|
||||
}
|
||||
// R5: latch the result ONLY if we actually provisioned something. This used to store
|
||||
// whatever `eps` held even when the loop broke on the first error — an empty vec —
|
||||
// and `OnceLock` made that permanent: one transient failure (a busy audio stack, a
|
||||
@@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via
|
||||
/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding
|
||||
/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened.
|
||||
///
|
||||
/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as
|
||||
/// a real DualSense speaker and route haptics audio at it — during a pad session that is the
|
||||
/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the
|
||||
/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint
|
||||
/// nothing services, and drops to 2–5 FPS 1% lows with the host completely idle; the manual
|
||||
/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and
|
||||
/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only
|
||||
/// an endpoint state notification — the same event a real pad's arrival/departure raises.
|
||||
pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) {
|
||||
match audio_control::set_endpoint_visibility(endpoint_id, visible) {
|
||||
Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id,
|
||||
state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" },
|
||||
"pad-audio endpoint visibility"),
|
||||
Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible,
|
||||
error = %format!("{e:#}"),
|
||||
"pad-audio endpoint visibility change failed — an idle visible pad speaker can \
|
||||
stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where
|
||||
/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and
|
||||
/// stall idle libScePad titles) forever.
|
||||
fn hide_leftover_endpoints() {
|
||||
let spawned = thread::Builder::new()
|
||||
.name("punktfunk-pad-audio-hide".into())
|
||||
.spawn(|| {
|
||||
if wasapi::initialize_mta().ok().is_err() {
|
||||
return;
|
||||
}
|
||||
for idx in 0..4u8 {
|
||||
match find(idx) {
|
||||
Ok(Some(pe)) if !pe.endpoint_id.is_empty() => {
|
||||
set_visibility(&pe.endpoint_id, idx, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep");
|
||||
}
|
||||
}
|
||||
|
||||
/// The provisioned endpoint for one pad slot — what a session queries when a client pad with
|
||||
/// speaker support arrives, to attach a [`PadLoopbackCapturer`].
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
|
||||
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
|
||||
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND
|
||||
//! recording devices are restored — both defaults are strictly session-scoped.
|
||||
|
||||
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
@@ -290,9 +290,13 @@ fn capture_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hand the default playback device back to the operator (no-op if we never parked it, or if
|
||||
// they changed it themselves mid-stream). COM is initialized on this thread.
|
||||
// Hand the default playback AND recording devices back to the operator (no-ops if we never
|
||||
// parked them, or if they changed them themselves mid-stream). COM is initialized on this
|
||||
// thread. The recording restore is what keeps the parked default session-scoped — an idle
|
||||
// box holding the default microphone on a virtual mic nothing feeds is the 2026-08
|
||||
// Helldivers 2 tank (see `audio_control`'s module docs).
|
||||
audio_control::restore_default_playback();
|
||||
audio_control::restore_default_recording();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
|
||||
// on the cable while later plans paired the default recording with the minted microphone
|
||||
// nothing wrote into (see `minted::ensure_blocking`). Instant once latched.
|
||||
super::minted::ensure_blocking();
|
||||
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the playback default (on the silent sink) for a stream's lifetime.
|
||||
// park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the box's defaults (playback on the silent sink, recording on the virtual
|
||||
// mic) for a stream's lifetime. An idle box must keep the operator's own devices default —
|
||||
// an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs).
|
||||
let mut wiring = audio_control::wire_now(false);
|
||||
if wiring.mic_render.is_none() && !wiring.mic_withheld {
|
||||
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
|
||||
|
||||
@@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool {
|
||||
lname.contains("steam streaming microphone")
|
||||
}
|
||||
|
||||
/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer
|
||||
/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The
|
||||
/// recording-default hygiene pass must never move the box's default onto one of these.
|
||||
pub(crate) fn virtual_capture(lname: &str) -> bool {
|
||||
lname.contains("cable output")
|
||||
|| lname.contains("steam streaming")
|
||||
|| lname.contains("voicemeeter")
|
||||
|| lname.contains("virtual")
|
||||
|| lname.contains("punktfunk")
|
||||
}
|
||||
|
||||
/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where
|
||||
/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic
|
||||
/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so
|
||||
/// the default is left alone.
|
||||
pub(crate) fn real_capture<'a>(
|
||||
captures: &'a [Endpoint],
|
||||
avoid_id: Option<&str>,
|
||||
) -> Option<&'a Endpoint> {
|
||||
captures
|
||||
.iter()
|
||||
.find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase()))
|
||||
}
|
||||
|
||||
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
|
||||
/// hardware — the best loopback source (apps render there by default and the operator can also
|
||||
/// hear it).
|
||||
@@ -1137,6 +1161,29 @@ mod tests {
|
||||
assert!(both.contains("16000") && both.contains("channel"), "{both}");
|
||||
}
|
||||
|
||||
/// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic,
|
||||
/// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact
|
||||
/// recording-tab zoo of the 2026-08-14 Helldivers 2 field box.
|
||||
#[test]
|
||||
fn recording_hygiene_picks_the_real_microphone() {
|
||||
let captures = [
|
||||
ep("Microphone (2- Punktfunk)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"),
|
||||
ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"),
|
||||
];
|
||||
assert_eq!(
|
||||
real_capture(&captures, None).unwrap().0,
|
||||
"Desktop Microphone (2- Microsoft LifeCam HD-3000)"
|
||||
);
|
||||
// `avoid_id` guards the plan's own mic capture even when its name would pass the
|
||||
// virtual test; with nothing else real, the answer is honestly None.
|
||||
let only = [ep("Desk Mic (USB)")];
|
||||
assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none());
|
||||
assert!(real_capture(&[], None).is_none());
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
|
||||
@@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint
|
||||
/// ensure|remove|status|tone|capture|show|hide [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's
|
||||
/// visibility (the host parks it hidden while no client pad is attached — show it before
|
||||
/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
|
||||
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` =
|
||||
@@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
// `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks
|
||||
// pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a
|
||||
// visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the
|
||||
// endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after.
|
||||
Some(verb @ ("show" | "hide")) => {
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint {verb}: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::set_visibility(&endpoint_id, idx, verb == "show");
|
||||
println!("pad-endpoint {verb}: {endpoint_id}");
|
||||
Ok(())
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"usage: punktfunk-host pad-endpoint \
|
||||
<ensure|remove|status|tone|capture|show|hide> [--index N]"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -397,11 +397,9 @@ fn audio_body(
|
||||
// stays small.
|
||||
let start = Instant::now();
|
||||
let mut frame_no: u64 = 0;
|
||||
// Optional linear gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0).
|
||||
let gain: f32 = std::env::var("PUNKTFUNK_AUDIO_GAIN")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
// Optional gain for quiet capture sources (PUNKTFUNK_AUDIO_GAIN, default 1.0). Soft-limited
|
||||
// rather than clamped — see `crate::audio::capture_gain`.
|
||||
let gain = crate::audio::capture_gain();
|
||||
tracing::info!(
|
||||
channels = layout.channels,
|
||||
streams = layout.streams,
|
||||
@@ -418,9 +416,7 @@ fn audio_body(
|
||||
while acc.len() >= frame_len {
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
for s in &mut frame {
|
||||
*s = (*s * gain).clamp(-1.0, 1.0);
|
||||
}
|
||||
punktfunk_core::audio::apply_gain(&mut frame, gain);
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out)?;
|
||||
// AES-128-CBC the Opus payload (RTP header stays plaintext). Per-packet IV =
|
||||
|
||||
@@ -442,6 +442,35 @@ pub fn validate_store_claim(store: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every `launcher_ui` entry naming a launcher this host cannot actually open, returning the
|
||||
/// `(title, value)` pairs removed.
|
||||
///
|
||||
/// The launch-side counterpart to [`sanitize_art_paths`], and it exists for the same reason: a
|
||||
/// plugin reconciles its **whole** entry set at once, so anything that fails the payload costs the
|
||||
/// operator every game in it. The Playnite plugin appends one launcher tile beside the games, so a
|
||||
/// host that could not resolve `Playnite.FullscreenApp.exe` refused the lot — the operator saw an
|
||||
/// empty grid and a `HostRequestError` naming `entries[9]`, with nothing to say the other entries
|
||||
/// were fine.
|
||||
///
|
||||
/// Only the *unresolvable* case is dropped. A value outside the platform's vocabulary is still a
|
||||
/// hard 400 in [`validate_provider_payload`]: that one is a bug in the plugin, and silently
|
||||
/// swallowing it would leave the author with a tile that never appears and no reason why.
|
||||
///
|
||||
/// Dropping the whole entry rather than clearing its `launch` is deliberate — a launcher tile with
|
||||
/// no launch is a dead tile, which is strictly worse than no tile.
|
||||
pub fn sanitize_launcher_entries(inputs: &mut Vec<ProviderEntryInput>) -> Vec<(String, String)> {
|
||||
let mut dropped = Vec::new();
|
||||
inputs.retain(|e| {
|
||||
let Some(launch) = &e.launch else { return true };
|
||||
if launch.kind != "launcher_ui" || resolvable_launcher_ui(&launch.value) {
|
||||
return true;
|
||||
}
|
||||
dropped.push((e.title.clone(), launch.value.clone()));
|
||||
false
|
||||
});
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
|
||||
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
|
||||
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
|
||||
@@ -467,12 +496,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
|
||||
));
|
||||
}
|
||||
// Refused rather than silently accepted, because the failure is otherwise invisible
|
||||
// until a user clicks the tile: an unresolvable value yields no command at launch time.
|
||||
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
|
||||
// Only the VOCABULARY is refused here. Whether the launcher is actually installed on
|
||||
// this box is not the payload's fault, and 400ing over it threw away every game in the
|
||||
// reconcile — see `sanitize_launcher_entries`, which drops just the tile instead.
|
||||
if launch.kind == "launcher_ui" && !known_launcher_ui(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
|
||||
cannot open (`{}`)",
|
||||
"entries[{i}]: `launch.value` for kind `launcher_ui` is not a launcher this \
|
||||
host's platform supports (`{}`)",
|
||||
launch.value
|
||||
));
|
||||
}
|
||||
@@ -1065,6 +1095,14 @@ mod tests {
|
||||
// Other kinds are unconstrained here (the host validates them per-kind at launch).
|
||||
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
|
||||
|
||||
// `launcher_ui` is checked for VOCABULARY only. A launcher that is merely not installed
|
||||
// must pass here and be dropped later — see `an_unopenable_launcher_tile_costs_only_itself`.
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "nonesuch")]).is_err());
|
||||
#[cfg(windows)]
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "playnite")]).is_ok());
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(validate_provider_payload(&[with_launch("launcher_ui", "lutris")]).is_ok());
|
||||
|
||||
let with_env = |key: &str, value: Option<&str>| {
|
||||
let mut i = input("a", "A");
|
||||
i.detect.env_marker = Some(EnvMarker {
|
||||
@@ -1129,4 +1167,40 @@ mod tests {
|
||||
"duplicate external_id"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression `sanitize_launcher_entries` exists for: a launcher tile this host cannot open
|
||||
/// must cost that tile, not the games reconciled beside it.
|
||||
///
|
||||
/// Field shape — the Playnite plugin appends exactly one `launcher_ui` tile after its games, so
|
||||
/// `entries[N]` failing validation used to refuse the entire payload and leave the operator with
|
||||
/// an empty grid and a `HostRequestError` that named only the index.
|
||||
#[test]
|
||||
fn an_unopenable_launcher_tile_costs_only_itself() {
|
||||
let mut tile = input("launcher", "Playnite");
|
||||
tile.role = GameRole::Launcher;
|
||||
tile.launch = Some(LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: "playnite".into(),
|
||||
});
|
||||
|
||||
let mut inputs = vec![input("a", "A"), tile, input("b", "B")];
|
||||
let dropped = sanitize_launcher_entries(&mut inputs);
|
||||
|
||||
if resolvable_launcher_ui("playnite") {
|
||||
// A Windows box with Playnite actually installed keeps all three.
|
||||
assert!(dropped.is_empty());
|
||||
assert_eq!(inputs.len(), 3);
|
||||
} else {
|
||||
// Everywhere else the tile goes and both games survive — the whole point of the split.
|
||||
assert_eq!(dropped.len(), 1);
|
||||
assert_eq!(dropped[0].1, "playnite");
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert!(inputs.iter().all(|e| e.external_id != "launcher"));
|
||||
}
|
||||
|
||||
// A payload of nothing but games is untouched on every OS.
|
||||
let mut only_games = vec![input("a", "A"), input("b", "B")];
|
||||
assert!(sanitize_launcher_entries(&mut only_games).is_empty());
|
||||
assert_eq!(only_games.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +478,31 @@ fn launcher_ui_stores() -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a `launcher_ui` value this host can resolve?
|
||||
/// Is `value` a launcher this host's platform knows about at all?
|
||||
///
|
||||
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
|
||||
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
|
||||
/// publishing a tile that does nothing when a user clicks it.
|
||||
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
if !launcher_ui_stores().contains(&value) {
|
||||
/// The *vocabulary* half of the old `valid_launcher_ui`. A value outside this set is a plugin
|
||||
/// author's mistake — a typo, or a launcher this OS has no support for — and no amount of
|
||||
/// installing things on the box will make it resolve, so the reconcile refuses the payload.
|
||||
pub(crate) fn known_launcher_ui(value: &str) -> bool {
|
||||
launcher_ui_stores().contains(&value)
|
||||
}
|
||||
|
||||
/// Can this host open `value`'s launcher **right now**?
|
||||
///
|
||||
/// The *environment* half. Deliberately separate from [`known_launcher_ui`], because the two
|
||||
/// failures are not the same kind of thing and must not get the same answer:
|
||||
///
|
||||
/// - an unknown value is a bug in the plugin, and a 400 is the only way its author finds out;
|
||||
/// - a known value that will not resolve means the launcher simply is not installed here, which is
|
||||
/// an ordinary fact about the box, not a defect in the payload.
|
||||
///
|
||||
/// Conflating them cost a real library: the Playnite plugin publishes one launcher tile alongside
|
||||
/// every game, so a host that could not resolve Playnite 400'd the whole reconcile and the operator
|
||||
/// got **no games at all** — the same shape as the unservable-cover bug that
|
||||
/// [`super::sanitize_art_paths`] was introduced to fix. The tile is dropped now (see
|
||||
/// [`super::sanitize_launcher_entries`]) and the games sync.
|
||||
pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
|
||||
if !known_launcher_ui(value) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
@@ -502,36 +520,141 @@ pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
|
||||
/// literal `"playnite"`.
|
||||
///
|
||||
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
|
||||
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
|
||||
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
|
||||
/// `None` when nothing resolves, which is what drops the tile.
|
||||
#[cfg(windows)]
|
||||
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
|
||||
const EXE: &str = "Playnite.FullscreenApp.exe";
|
||||
|
||||
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.find_map(|root| {
|
||||
RegKey::predef(root)
|
||||
.open_subkey(KEY)
|
||||
.ok()?
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()
|
||||
})
|
||||
.map(std::path::PathBuf::from);
|
||||
|
||||
from_registry
|
||||
.into_iter()
|
||||
.chain(
|
||||
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
|
||||
)
|
||||
.map(|dir| dir.join(EXE))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Windows: every directory that might hold a Playnite install, best candidates first.
|
||||
///
|
||||
/// **Playnite installs per-user by default, and this host is a LocalSystem service** — which
|
||||
/// invalidates all three of the obvious lookups, and is why this is not a two-liner:
|
||||
///
|
||||
/// - `HKEY_CURRENT_USER` is *SYSTEM's own* hive (`S-1-5-18`), never the person's, so a per-user
|
||||
/// install is invisible there. Every **loaded** hive under `HKEY_USERS` is read instead: only
|
||||
/// logged-on users' hives are loaded, which is exactly the set that can be streaming, and it
|
||||
/// avoids a `WTSQueryUserToken` dance for what is a best-effort probe. Same trade-off
|
||||
/// [`crate::procscan::steam_running_hint`] makes, for the same reason.
|
||||
/// - The uninstall subkey is matched by its **`DisplayName`**, not by key name. Playnite ships an
|
||||
/// Inno Setup installer and Inno registers `<AppId>_is1` — measured on a Windows box where Git
|
||||
/// and Inno itself appear as `Git_is1` and `Inno Setup 6_is1`. The hardcoded
|
||||
/// `…\Uninstall\Playnite` this replaced matched nothing on any box.
|
||||
/// - `%LOCALAPPDATA%` for a SYSTEM service is `C:\Windows\System32\config\systemprofile\AppData\
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
#[cfg(windows)]
|
||||
fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_LOCAL_MACHINE, HKEY_USERS, KEY_READ};
|
||||
use winreg::RegKey;
|
||||
|
||||
// 64-bit and 32-bit views. HKCU/HKU `Software` is not redirected (only `Software\Classes` is),
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
if sid.ends_with("_Classes") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
// own default install lands, and it covers a user whose hive is not currently loaded.
|
||||
for profile in windows_user_profiles() {
|
||||
push_unique(&mut dirs, profile.join(r"AppData\Local\Playnite"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Collect `InstallLocation` from every Playnite-looking uninstall entry under `root\path`.
|
||||
///
|
||||
/// Matched on `DisplayName` because the key name is the installer's `AppId` (see
|
||||
/// [`playnite_install_dirs`]). `starts_with` rather than equality so a versioned or suffixed display
|
||||
/// name still counts; the value is only ever used as a directory to probe for the exe, so a false
|
||||
/// positive costs one failed `is_file`.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dirs_from_uninstall(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(uninstall) = root.open_subkey_with_flags(path, KEY_READ) else {
|
||||
return;
|
||||
};
|
||||
for name in uninstall.enum_keys().flatten() {
|
||||
let Ok(entry) = uninstall.open_subkey_with_flags(&name, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
let display: String = entry.get_value("DisplayName").unwrap_or_default();
|
||||
if !display.starts_with("Playnite") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(location) = entry.get_value::<String, _>("InstallLocation") {
|
||||
let location = location.trim();
|
||||
if !location.is_empty() {
|
||||
push_unique(out, std::path::PathBuf::from(location));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
/// [`super::art::art_roots`] uses — with `%SystemDrive%\Users` as the fallback when the variable is
|
||||
/// missing from a service's environment.
|
||||
#[cfg(windows)]
|
||||
fn windows_user_profiles() -> Vec<std::path::PathBuf> {
|
||||
let base = std::env::var_os("PUBLIC")
|
||||
.map(std::path::PathBuf::from)
|
||||
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
|
||||
.or_else(|| {
|
||||
std::env::var_os("SystemDrive").map(|d| std::path::PathBuf::from(d).join("Users"))
|
||||
});
|
||||
let Some(base) = base else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir() && !p.ends_with("Public"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push `path` unless an equal one is already there — the candidate lists are a handful of entries,
|
||||
/// so a linear check beats carrying a set around.
|
||||
#[cfg(windows)]
|
||||
fn push_unique(out: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
|
||||
if !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
@@ -800,33 +923,38 @@ mod tests {
|
||||
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert!(valid_launcher_ui("heroic"));
|
||||
assert!(valid_launcher_ui("lutris"));
|
||||
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(known_launcher_ui("heroic"));
|
||||
assert!(known_launcher_ui("lutris"));
|
||||
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
|
||||
// becoming a tile that does nothing.
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
|
||||
// validation is resolution, so a box without Playnite refuses the entry rather than
|
||||
// publishing a tile that does nothing when clicked.
|
||||
// Playnite is in the vocabulary unconditionally — whether this particular box has it
|
||||
// installed is a separate question, answered by `resolvable_launcher_ui` below. Keeping
|
||||
// them separate is the fix for the reconcile that 400'd a whole library over one tile.
|
||||
assert!(known_launcher_ui("playnite"));
|
||||
assert_eq!(
|
||||
valid_launcher_ui("playnite"),
|
||||
resolvable_launcher_ui("playnite"),
|
||||
playnite_fullscreen_exe().is_some()
|
||||
);
|
||||
// The Linux launchers, and the Windows ones whose activation is still unverified
|
||||
// (Epic, GOG Galaxy, the Xbox app), stay refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(!known_launcher_ui("heroic"));
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
// No launcher UIs are wired on this OS, so every value is refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
assert!(!known_launcher_ui("heroic"));
|
||||
assert!(!known_launcher_ui("gog"));
|
||||
}
|
||||
assert!(!valid_launcher_ui(""));
|
||||
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
|
||||
// Junk is outside the vocabulary on every OS, so it never reaches a resolver.
|
||||
assert!(!known_launcher_ui(""));
|
||||
assert!(!known_launcher_ui("lutris; rm -rf ~"));
|
||||
assert!(!resolvable_launcher_ui(""));
|
||||
assert!(!resolvable_launcher_ui("lutris; rm -rf ~"));
|
||||
}
|
||||
|
||||
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
|
||||
|
||||
@@ -524,6 +524,18 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
// A launcher this box cannot open is a fact about the box, not a defect in the payload, so it
|
||||
// costs its own tile and nothing else. Before this, the Playnite plugin's single launcher entry
|
||||
// 400'd every game it shipped alongside.
|
||||
for (title, value) in crate::library::sanitize_launcher_entries(&mut inputs) {
|
||||
tracing::warn!(
|
||||
provider,
|
||||
launcher = %value,
|
||||
title = %title,
|
||||
"library reconcile: dropped a launcher tile this host cannot open — the rest of the \
|
||||
payload still syncs. Install the launcher, or turn the tile off in the plugin's config"
|
||||
);
|
||||
}
|
||||
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
|
||||
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
|
||||
let mut dropped_art = 0usize;
|
||||
|
||||
@@ -142,6 +142,20 @@ pub(super) fn audio_thread(
|
||||
};
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * want as usize;
|
||||
// Operator capture gain, soft-limited (`PUNKTFUNK_AUDIO_GAIN`, default 1.0 = untouched). This
|
||||
// plane had NO gain at all until now, so `PUNKTFUNK_AUDIO_GAIN` silently did nothing on
|
||||
// punktfunk/1 while working on GameStream — and since WASAPI loopback taps upstream of the
|
||||
// endpoint's master volume, there was no other host-side way to lift a quiet desktop mix.
|
||||
// Read once per session rather than per frame: this is an operator setting, not a live control.
|
||||
let gain = crate::audio::capture_gain();
|
||||
if gain != 1.0 {
|
||||
tracing::info!(
|
||||
gain,
|
||||
"audio: applying operator capture gain (soft-limited above \
|
||||
{}; headroom, not loudness)",
|
||||
punktfunk_core::audio::SOFT_LIMIT_KNEE
|
||||
);
|
||||
}
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
@@ -253,7 +267,10 @@ pub(super) fn audio_thread(
|
||||
}
|
||||
pace_due = Some(pace_due.unwrap_or_else(std::time::Instant::now) + FRAME_INTERVAL);
|
||||
|
||||
let frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
if gain != 1.0 {
|
||||
punktfunk_core::audio::apply_gain(&mut frame, gain);
|
||||
}
|
||||
let pts_ns = next_pts_ns;
|
||||
next_pts_ns += FRAME_MS as u64 * 1_000_000;
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
|
||||
@@ -321,16 +321,26 @@ pub(super) fn spawn(
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
let endpoint_id = ep.endpoint_id;
|
||||
let vis_id = endpoint_id.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || {
|
||||
// COM for the visibility flips (the capturer's opens run on their own thread).
|
||||
let _ = wasapi::initialize_mta();
|
||||
// The endpoint parks HIDDEN while no pad is attached — an idle visible "Wireless
|
||||
// Controller" speaker makes libScePad titles engage their DualSense-haptics path
|
||||
// against an endpoint nothing services (the 2026-08-14 Helldivers 2 field tank).
|
||||
// Show it for exactly this pad's lifetime, like a real DualSense arriving; the
|
||||
// capturer's open/backoff loop absorbs the moment audiosrv takes to re-activate.
|
||||
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, true);
|
||||
pad_audio_thread(
|
||||
conn,
|
||||
pad,
|
||||
kinds,
|
||||
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
|
||||
stop_t,
|
||||
)
|
||||
);
|
||||
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, false);
|
||||
}) {
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the
|
||||
//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes).
|
||||
//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating
|
||||
//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the
|
||||
//! `CERT_E_UNTRUSTEDROOT` (canary and local builds still sign with a self-signed cert, and
|
||||
//! releases moved to Azure Artifact Signing without needing this to tighten); when the
|
||||
//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken
|
||||
//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second
|
||||
//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the
|
||||
//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once
|
||||
//! the cert story settles — the field exists so Trusted Signing is a manifest edit).
|
||||
//! manifest hash already binds content).
|
||||
//!
|
||||
//! **Leaf pinning cannot be used with Azure Artifact Signing.** That service mints a fresh leaf
|
||||
//! per signing request, valid ~3 days, so an `AUTHENTICODE_SHA256` pin would go stale within days
|
||||
//! of publishing and reject every subsequent release. (An earlier note here assumed the opposite —
|
||||
//! that the pin field made Trusted Signing "a manifest edit". It does not.) If pinning is wanted
|
||||
//! against the Azure-signed artifacts, pin something stable instead: the issuing intermediate, or
|
||||
//! the certificate subject. Leave the list empty until then; the Ed25519-signed manifest hash is
|
||||
//! what actually binds the downloaded bytes.
|
||||
//!
|
||||
//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close
|
||||
//! (a stopping service would otherwise take the installer down with it) and was created
|
||||
@@ -284,8 +292,10 @@ fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`),
|
||||
/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same
|
||||
/// Authenticode: valid embedded signature (untrusted root tolerated — canary/local builds are still
|
||||
/// self-signed), signing-leaf SHA-256 ∈ `pins` when pins are present — but see the module docs: a
|
||||
/// leaf pin is unusable against Azure-signed releases, whose leaf rotates every few days. The leaf
|
||||
/// comes out of the same
|
||||
/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service
|
||||
/// supervisor's boot-loop rollback re-checks the cached previous installer with it.)
|
||||
pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> {
|
||||
|
||||
+537
-191
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
|
||||
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
|
||||
| `PUNKTFUNK_PORTAL_CURSOR_MODE` | `auto` *(default)* · `embedded` · `metadata` · `hidden` | **Hyprland / wlroots only, and a troubleshooting knob** — which ScreenCast cursor mode the host asks the portal for. Unset, the host asks for `metadata` when the client draws the pointer itself and `embedded` otherwise, then settles that against the modes your portal advertises; it never requests one your portal lacks. Set `embedded` if the pointer misbehaves on a portal that *claims* metadata support but implements it poorly — that is the one case the automatic negotiation cannot detect. A pin is still only a preference: it is checked against the advertised modes like any other. |
|
||||
|
||||
## Session recovery (Linux)
|
||||
|
||||
@@ -155,7 +156,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_AUDIO_QUALITY` | `low` · `standard` · `high` *(default `high`)* | Desktop-audio encode quality. `high` (stereo 256 kbps Opus, effectively transparent) costs about 1 % of a normal video bitrate, so there's rarely a reason to go lower. `standard` is exactly the pre-0.25 encoder (stereo 128 kbps) — handy for an A/B comparison; `low` is for genuinely constrained links (noticeably lossy on music, still fine for game audio and voice). A typo warns in the log and keeps `high` rather than silently downgrading. Host-side only — clients play whatever arrives, no client setting involved. |
|
||||
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
|
||||
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | **(Moonlight/GameStream sessions only)** Linear gain applied to captured desktop audio — bump it for a quiet source. The native `punktfunk/1` path ignores it; adjust the source's own volume there instead. |
|
||||
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | Gain applied to captured desktop audio — bump it for a quiet source. Applies to **both** the native `punktfunk/1` and Moonlight/GameStream paths. Peaks are rounded off by a soft limiter rather than clipped, so a boost distorts gracefully instead of abruptly; values above `8.0` (+18 dB) are capped, and a non-positive value is ignored. Note this buys **headroom, not loudness** — it cannot make a desktop mix as loud as already-limited streaming-app audio, and pushing it hard to try will audibly squash the signal. On Windows this is the only host-side control that works at all: loopback capture is tapped upstream of the endpoint's master volume, so the speaker slider does not affect what a client receives. |
|
||||
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
|
||||
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
|
||||
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
|
||||
|
||||
@@ -84,6 +84,31 @@ and fails the session with a clear error rather than streaming a blank surface.
|
||||
capture the Hyprland log (`hyprctl` instance dir → `hyprland.log`) and check your GPU's GBM support;
|
||||
running Hyprland as a real session (not nested) is the supported configuration.
|
||||
|
||||
## Troubleshooting: black client + "unavailable cursor mode 4"
|
||||
|
||||
A black client, `pipeline build failed` in the host log, and **`unavailable cursor mode 4`** from
|
||||
xdph are one failure, not three.
|
||||
|
||||
`4` is the ScreenCast portal's *metadata* cursor mode, which the host prefers when the client draws
|
||||
the pointer locally (desktop mouse mode). xdg-desktop-portal-hyprland **does not offer that mode** —
|
||||
on a current stack (Hyprland 0.56.2, xdph 1.4.1) its `AvailableCursorModes` is `3`, meaning hidden
|
||||
and embedded only. Asking for a mode the backend does not advertise is not a soft failure:
|
||||
`xdg-desktop-portal` rejects the call outright, so the cast died during setup and the client had
|
||||
nothing to show.
|
||||
|
||||
Updating xdph does **not** fix this — the mode is absent on current versions, not just old ones.
|
||||
Hosts from this release check what your portal advertises and use an embedded cursor instead, so the
|
||||
session streams. If you are on an older host, switch the client to **game mouse mode**: that stops
|
||||
it asking for the metadata cursor at all.
|
||||
|
||||
If the pointer misbehaves on an xdph that *does* advertise metadata support, pin the mode:
|
||||
|
||||
```sh
|
||||
PUNKTFUNK_PORTAL_CURSOR_MODE=embedded
|
||||
```
|
||||
|
||||
See [Configuration](/docs/configuration#compositor-specific-linux).
|
||||
|
||||
## Permission system
|
||||
|
||||
Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny
|
||||
|
||||
@@ -82,6 +82,20 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see
|
||||
Then `systemctl --user restart xdg-desktop-portal`. On a box with only xdpw installed there is
|
||||
nothing to choose between, so you can skip this.
|
||||
|
||||
## Troubleshooting: black client + "unsupported cursor mode requested"
|
||||
|
||||
A black client with `pipeline build failed` in the host log and **`dbus: unsupported cursor mode
|
||||
requested, cancelling`** from xdpw is one failure, not two.
|
||||
|
||||
xdpw refuses the ScreenCast *metadata* cursor mode and cancels the cast, and the portal spec makes
|
||||
that fatal rather than a fallback. Hosts before this release asked for it whenever the client drew
|
||||
the pointer itself (desktop mouse mode), so those sessions never produced a frame. Hosts from this
|
||||
release check what xdpw advertises first and use an embedded cursor instead, so the session streams.
|
||||
|
||||
On an older host, switch the client to **game mouse mode** — it stops asking for the metadata cursor
|
||||
and the stream comes up. The same failure on Hyprland reads `unavailable cursor mode 4`; see
|
||||
[Hyprland](/docs/hyprland).
|
||||
|
||||
## Start the host
|
||||
|
||||
With the backend selected, start the host from **inside your Sway session**:
|
||||
|
||||
+20
-20
@@ -10,30 +10,30 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@scalar/api-reference-react": "^0.9.47",
|
||||
"@tanstack/react-router": "^1.121.0",
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/app-ui": "^0.1.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@scalar/api-reference-react": "^0.9.63",
|
||||
"@tanstack/react-router": "^1.170.28",
|
||||
"@tanstack/react-start": "^1.168.45",
|
||||
"@unom/app-ui": "^0.2.1",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"@unom/ui": "^0.9.2",
|
||||
"fumadocs-core": "^16.14.4",
|
||||
"fumadocs-ui": "^16.14.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"fumadocs-mdx": "^15.0.12",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.0"
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"fumadocs-mdx": "^15.2.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.28.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -45,6 +45,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair every client",
|
||||
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
|
||||
"operationId": "unpairAllClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients/{fingerprint}": {
|
||||
@@ -1767,6 +1797,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Unpair every native client",
|
||||
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
|
||||
"operationId": "unpairAllNativeClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every native client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"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/clients/{fingerprint}": {
|
||||
@@ -7687,6 +7767,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnpairAllResult": {
|
||||
"type": "object",
|
||||
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
|
||||
"required": [
|
||||
"unpaired"
|
||||
],
|
||||
"properties": {
|
||||
"unpaired": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Clients removed from the trust store — 0 when nothing was paired.",
|
||||
"example": 3,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
Wire-compatible with 0.28.0 — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
This is a fix release, and most of it is about things that were wrong in ways nothing announced. On Windows, your Steam library could stay completely empty after the last update, and a Punktfunk host sitting idle — nothing connected, nothing streaming — could wreck the frame rate of a game you were playing on that same machine. Streaming from a Mac with the microphone on cut the sound and froze the controls every couple of seconds. An Android TV box could be silent for a whole session; a Steam Deck's Punktfunk home would not open; games could not reach high dynamic range (HDR) at all on Linux machines running our own compositor, while the Steam interface in front of them glared; and an Apple TV was being pushed into HDR mode by streams that were not HDR at all, lifting blacks to grey.
|
||||
|
||||
Several of these came from people who reported them carefully enough to be found. One of them, the crash that lost your game on reconnect, was tracked down, fixed and proven on his own hardware by **luxus** — that fix is in this release as he wrote it.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **On Windows, your Steam games went missing after 0.28.0** — the add-on could publish nothing, because Steam keeps its artwork somewhere the host was not allowed to read.
|
||||
- **An idle host could ruin a game running on that same Windows machine**, down to 2–5 frames per second, with nothing connected and no stream running.
|
||||
- **Streaming from a Mac with your microphone on cut the sound and froze your controls every couple of seconds** — and turning the mic off was the only way round it.
|
||||
- **No sound at all on some Android TV boxes**, an NVIDIA Shield in particular — and sound now returns by itself when your TV changes mode mid-stream.
|
||||
- **The Punktfunk home would not open on a Steam Deck after 0.28.0**, and streams elsewhere lost their statistics overlay to the same fault.
|
||||
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway** — every current install, not just old ones.
|
||||
- **High dynamic range was broken in both directions on Linux hosts:** games could not reach it at all, and everything else was mapped a stop too bright. Both fixed.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **On Windows, two things in your Sound settings will look different, and both are deliberate.** "Speakers (Wireless Controller)" now shows as *disabled* whenever no controller is connected through Punktfunk — that is it parked out of the way of your local games, not a fault, and it comes back on its own. And Punktfunk no longer keeps your default microphone: it borrows it for the length of a stream and gives it back. If you actually want apps to record the client's microphone while nothing is streaming, select "Punktfunk Microphone" yourself — your choice will now be left alone.
|
||||
- **Only if you are coming from 0.27 or older:** read [the 0.28.0 notes](https://git.unom.io/unom/punktfunk/releases/tag/v0.28.0) first. That release has two things that need you to act — your game library now comes from add-ons you install per store (with none installed, your library is empty), and Moonlight-compatible connections became something you switch on. Neither applies if you are already on 0.28.0.
|
||||
|
||||
## New
|
||||
|
||||
- **Your saved hosts have a menu when you use a controller on Apple devices.** Press up on a host tile for Wake, Copy link, Edit, Forget pairing and Remove — the same menu the desktop and Android consoles already had, so all three work the same way. A renamed machine or a mistyped address used to stay wrong forever unless you switched to touch. Removing asks twice, deliberately, because a thumbstick from across the room deserves at least as much care as a dialog.
|
||||
- **The shortcut list is now a page you can open.** It used to appear as a banner over the stream for six seconds right after connecting — which is the one moment nobody is asking what the controls are. It lives in an About section you can visit whenever you want it.
|
||||
- **Unpair every device at once.** Clearing a host's paired devices meant deleting them one at a time, confirming each. There is now a single action for it on the Paired devices card, and it ends any live session belonging to a device it removes.
|
||||
|
||||
## Improved
|
||||
|
||||
- **High dynamic range brightness matches between host and client.** Anything that is not an HDR game — your desktop, the Steam overlay, an ordinary title — was being mapped nearly a stop brighter by the host than every Punktfunk client expects. That is what made the Steam interface look glaring and over-saturated while actual HDR game content looked flat and washed out on the same stream. Both come from the same mistake and both are fixed.
|
||||
- **Your host tells you when high dynamic range cannot work.** A session could start advertising HDR while having made it unreachable for every game inside it, with nothing anywhere explaining the washed-out result. It now says so.
|
||||
- **The on-screen statistics no longer double-count decoding.** On machines using the fastest decoding path, the stage breakdown listed a figure that was already included in another one, so the parts did not add up to the total and a reader who checked was right to be confused. It is now reported separately, labelled as the single sample it is.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **On Windows, the Steam add-on synced nothing and your grid stayed empty.** Steam installs to Program Files and keeps its artwork there, and the host's list of places it is allowed to read art from covered the launchers that install per-user — but not Steam. Every cover the add-on offered was rejected, and because one bad cover failed the whole submission, that meant every Steam game rather than one thumbnail. Both halves are fixed: Steam's own install locations are now allowed (including on a second drive), and a cover that cannot be served is dropped so the rest of your library still arrives. This only affected Windows — every Steam layout on Linux was already covered — and it arrived with 0.28.0, when the built-in Steam scanner was replaced by the add-on.
|
||||
- **A Punktfunk host that was doing nothing at all could wreck a game running on the same Windows machine** — 2–5 frames per second at worst, with no client connected and no stream ever started. Two independent causes, both from the same set of reports: the host presents a controller speaker that looks exactly like a real DualSense's, and a game that finds it engages its DualSense audio path against a device nothing is feeding; and the host was claiming your default microphone permanently, so any game holding an open voice input stalled on a virtual mic that only runs during a stream. The controller speaker is now hidden except while a controller is actually attached, and your microphone is only borrowed for the length of a stream. Machines already stuck repair themselves on the next start.
|
||||
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway.** Punktfunk asked the desktop's screen-sharing service for the pointer in a form neither compositor offers, and the request was refused before it ever reached them — so the whole capture failed rather than losing just the pointer. It now asks what is actually available and takes the best on offer. Measured against fully up-to-date Hyprland and portal versions: this was every current install, and updating them would not have helped. You will now see a single pointer drawn by the host instead of your client drawing its own.
|
||||
- **Streaming from a Mac with the microphone on cut the audio and froze the controls on a two-and-a-half second beat**, while the picture carried on untouched. Some microphones — a multi-channel audio interface set as your Mac's input, for instance — cannot run the echo-cancelling mode Punktfunk asks for. Each failed attempt knocked out the working audio path, and the knock-out was itself the signal that triggered the next attempt, so it went round forever. Your controls froze on the same beat because that rebuild happened on the part of the app that also sends your keyboard and mouse. All three links in that loop are broken now, and audio setup no longer happens where your input lives — so even a genuine change of sound device no longer costs you control of the stream. Until now the only workaround was turning the client's microphone off.
|
||||
- **An Android TV box could have no audio for an entire session.** Reported on an NVIDIA Shield with a host and settings that played fine everywhere else, and video unaffected. The app asked Android for an audio device and then trusted the answer: a device that accepted the request but played nothing was never noticed, a device that refused to start disabled sound for the rest of the session, and — most common on a television — a device that went away during an HDMI mode change was never reopened. It now proves that sound is actually being taken before it accepts a device, and reopens automatically when your TV or receiver changes mode. TV boxes now use the ordinary shared audio path rather than the exclusive low-latency one, which was never buying them the latency it cost.
|
||||
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** Both the Decky button and the library shortcut failed. The same fault silently removed the statistics and capture overlays inside streams on other machines. It came from a graphics library sizing itself to what the machine's graphics driver supported rather than to what Punktfunk had asked for, which only became a problem once drivers moved ahead of us.
|
||||
- **Games could not use high dynamic range on Linux hosts running Punktfunk's compositor** — on essentially every machine, not a rare configuration. Reaching HDR requires a small graphics component that has to match the compositor exactly, and we shipped the compositor without it, leaving the host guessing whether the one your distribution provided was compatible. Because we build our own compositor, that guess was almost always wrong in the direction of switching HDR off. We now ship the matching component ourselves, on every Linux packaging channel.
|
||||
- **Reconnecting after a disconnect could lose your game** on Linux hosts with an NVIDIA card. The host keeps your session alive when a client leaves so reconnecting resumes it, and the compositor could crash in exactly that window — so the session you came back to was a brand-new one with your game gone. Found, fixed and proven in the field by **luxus**, over an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before the fix and none after.
|
||||
- **A stream that was not HDR pushed an Apple TV into HDR mode anyway**, which switches the HDMI output to limited range — so on a television set for full range, black came out grey. The switch was triggered by your HDR *setting* being on rather than by the stream actually being HDR, and that setting is on by default.
|
||||
- **Ordinary streams on Apple devices were drawn with no colour information at all**, leaving them mildly over-saturated on a Mac or iPad with a wide-colour screen, and lifting blacks on an Apple TV. A stream that was not HDR from its very first frame skipped colour setup entirely.
|
||||
- **The statistics overlay on Apple devices reported numbers that could not be true** — a network figure lower than the host's own encoding time, and historically readings of zero. Three faults: the clock difference with the host was measured once at connect and never updated (it drifts, and on a virtual-machine host it jumps), impossible samples were being discarded silently rather than invalidating the reading, and several unavailable values printed as "NaN". The overlay now warns you when a reading is suspect instead of showing a plausible wrong number.
|
||||
- **AV1 streams fell back to slower decoding** on hardware that decodes them perfectly well. Some encoders mark a stream as "not restricted to any quality level", and that marker was being read as a level so high that no device could claim to support it.
|
||||
- **On Apple TV, console text could be white on a pale background**, making screens unreadable with the light appearance.
|
||||
- **Punktfunk add-ons could not be installed on NixOS**, where the component that manages them was reported missing on machines where it was installed, enabled and running.
|
||||
- **On NixOS the host could crash-loop forever, unable to claim its own ports.** A second copy was being started under the root account — which springs into life the moment anyone signs in as root — and it won the race for the ports the real one needed. The log blamed some other program, because a second copy of itself is the last thing anyone looks for. The service is now scoped to the accounts you list.
|
||||
- **Two host warnings blamed the wrong thing.** One announced a "metronomic" disturbance from your display hardware when the regular timing was in fact a client's own recovery interval; the other reported catastrophic audio loss between sessions, when what it had counted was silence during the seconds no one was connected. Neither ever indicated a real fault.
|
||||
|
||||
## Thanks
|
||||
|
||||
**luxus** found the crash that lost your game on reconnect, wrote the fix, and proved it on his own machine — an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before it and none after. It ships here as he wrote it.
|
||||
|
||||
Most of the rest of this release exists because people took the trouble to report a fault precisely: the silent Android TV box, the empty Steam library, the game stuttering next to an idle host, the washed-out HDR, the black picture on Hyprland. Thank you — that detail is what makes a fault findable.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table and the (empty) breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.1/CHANGELOG.md).
|
||||
|
||||
Nothing breaks: the wire protocol, the C ABI, the virtual-display driver protocol and the plugin contract are all unchanged from v0.28.0. Three things are worth reading if they apply to you: every Linux packaging channel now ships a second gamescope artifact and a package without it fails to build rather than degrading quietly, Android's `nativeStartAudio` takes one more argument, and `SessionAudio.start()` is now asynchronous on macOS as it already was on iOS and tvOS — playback is live shortly after the call rather than on return.
|
||||
@@ -0,0 +1,3 @@
|
||||
• Fixes having no sound at all on some Android TV boxes, the NVIDIA Shield among them. The app now checks that your TV or receiver is really taking the audio, instead of trusting that it will.
|
||||
• Sound returns on its own when your TV or receiver changes mode during a stream. Before, it could stay silent for the rest of the session.
|
||||
• A stream that loses audio now says so, rather than looking exactly like an app with nothing to play.
|
||||
@@ -64,7 +64,7 @@ Add the flake and enable the host and/or client:
|
||||
({ ... }: {
|
||||
services.punktfunk.host = {
|
||||
enable = true;
|
||||
users = [ "alice" ]; # → added to the `input` group for virtual gamepads
|
||||
users = [ "alice" ]; # → `input` group for gamepads, AND scopes the units to alice
|
||||
openFirewall = true; # native + GameStream ports
|
||||
desktopSession = true; # a machine you log into — restart the host with the desktop
|
||||
settings = {
|
||||
@@ -236,6 +236,14 @@ services.punktfunk.host = {
|
||||
users.users.streamer.linger = true;
|
||||
```
|
||||
|
||||
**Set `users` whenever `autoStart` is on.** `systemd.user.*` installs into *every* user's systemd
|
||||
manager — root's included, and root gets one the moment anybody logs in as root. Without `users` to
|
||||
scope them, `autoStart` therefore starts a *second* host in root's manager, which wins the race for
|
||||
the fixed ports and leaves the real one restarting forever on `bind RTSP 48010: Address already in
|
||||
use` (every other listener having bound fine, so it reads like a clash with an unrelated program).
|
||||
The module renders `ConditionUser=` from this list to prevent that; with the list empty it falls
|
||||
back to refusing system users, which keeps root out but cannot tell two logins apart.
|
||||
|
||||
Leave `desktopSession` off here — an appliance starts its own compositor and may never reach
|
||||
`graphical-session.target`, which would leave the host permanently stopped. `gamescopeHdr` (on by
|
||||
default) already puts the patched `punktfunk-gamescope` on the service PATH, so the gamescope
|
||||
|
||||
@@ -143,6 +143,39 @@ let
|
||||
ok = failedAssertions clientOnly == [ ];
|
||||
}
|
||||
|
||||
# --- user scoping: the second-copy-steals-the-ports trap -----------------------------------
|
||||
# `systemd.user.*` installs into EVERY user's manager, root's included (user@0.service exists
|
||||
# as soon as anyone logs in as root), and `autoStart` puts these in default.target. Root's host
|
||||
# then wins the fixed ports and the desktop user's restarts forever on
|
||||
# `bind RTSP 48010: Address already in use` — every other listener in its log having bound
|
||||
# fine, so it reads like an unrelated program. MEASURED on a real box before this was fixed.
|
||||
{
|
||||
# `|` = TRIGGERING condition, which systemd ORs. Plain repeated ConditionUser= lines are
|
||||
# ANDed and would match nobody — the whole reason the prefix is there.
|
||||
name = "host.users scopes every user unit to those users, OR-ed";
|
||||
ok =
|
||||
let
|
||||
scoped = name: has desktop name "ConditionUser=|alice";
|
||||
in
|
||||
scoped "punktfunk-host" && scoped "punktfunk-web" && scoped "punktfunk-scripting";
|
||||
}
|
||||
{
|
||||
# web-init already carried a ConditionPathExists. That one is NON-triggering, so systemd
|
||||
# requires it AND at least one triggering user condition — adding ours must not drop it.
|
||||
name = "web-init keeps its path condition alongside the user scope";
|
||||
ok =
|
||||
has desktop "punktfunk-web-init" "ConditionUser=|alice"
|
||||
&& has desktop "punktfunk-web-init" "ConditionPathExists=!%h/.config/punktfunk/web-password";
|
||||
}
|
||||
{
|
||||
# With no host.users to name, still keep SYSTEM users (root) out, while leaving the module
|
||||
# header's manual `systemctl --user enable --now punktfunk-host` working for a normal login.
|
||||
name = "with no host.users, the units still refuse system users (root)";
|
||||
ok =
|
||||
has appliance "punktfunk-host" "ConditionUser=!@system"
|
||||
&& !(has appliance "punktfunk-host" "ConditionUser=|");
|
||||
}
|
||||
|
||||
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
|
||||
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
|
||||
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
|
||||
|
||||
@@ -55,6 +55,27 @@ let
|
||||
|
||||
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
|
||||
|
||||
# WHICH users' `systemd --user` instances may run these units — and why they need saying at all.
|
||||
#
|
||||
# `systemd.user.*` installs into EVERY user's manager; there is no per-user form of it in NixOS.
|
||||
# Combined with `autoStart` putting the units in `default.target`, that includes **root**, whose
|
||||
# `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
|
||||
# copy of the host then wins the race for the fixed ports and the desktop user's copy fails
|
||||
# forever on `bind RTSP 48010: Address already in use` — with every other listener in its log
|
||||
# having bound fine, so it reads like a clash with some unrelated program rather than a second
|
||||
# copy of itself. MEASURED 2026-08-14 on a fresh NixOS 26.05 box.
|
||||
#
|
||||
# `host.users` is already documented as "the host runs as these users' systemd --user service",
|
||||
# so it is the right scope. When it is empty we cannot name the intended user, so fall back to
|
||||
# excluding system users — which is precisely what keeps root out — and leave the module header's
|
||||
# manual `systemctl --user enable --now punktfunk-host` route working for any normal login.
|
||||
#
|
||||
# ⚠ The `|` prefix is load-bearing: it makes each entry a TRIGGERING condition, and systemd ORs
|
||||
# those. Plain repeated `ConditionUser=` lines are ANDed, so a two-user list would match NOBODY.
|
||||
# Non-triggering conditions on the same unit (punktfunk-web-init's ConditionPathExists) still
|
||||
# have to hold, which is the behaviour we want.
|
||||
userScope = if cfg.host.users == [ ] then [ "!@system" ] else map (u: "|${u}") cfg.host.users;
|
||||
|
||||
# Native punktfunk/1 ports (control plane + discovery + mgmt API). The media data plane is an
|
||||
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
|
||||
# packaging/linux/punktfunk.ufw).
|
||||
@@ -106,6 +127,10 @@ in
|
||||
Start the host automatically in every user's graphical session (adds it to the user
|
||||
`default.target`). For a login-less appliance, also enable lingering for the host user
|
||||
(`users.users.<name>.linger = true`) so the user service comes up at boot.
|
||||
|
||||
"Every user" is bounded by `host.users` via `ConditionUser=` — without that bound this
|
||||
option also starts a host in ROOT's user manager the moment anybody logs in as root, and
|
||||
that copy takes the ports from the real one. Set `host.users` on a multi-user box.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -147,6 +172,15 @@ in
|
||||
usbip/vhci nodes the virtual Steam Deck pad attaches through. The second is separate on
|
||||
purpose — it can emulate arbitrary USB hardware, so only list users you would trust with
|
||||
that. The host runs as these users' `systemd --user` service.
|
||||
|
||||
This list ALSO scopes the units themselves: they carry a `ConditionUser=` for these
|
||||
users, so no other user's `systemd --user` instance can start them. That matters because
|
||||
`systemd.user.*` installs into every user's manager — including root's, which exists as
|
||||
soon as anyone logs in as root — and a second host silently wins the race for the fixed
|
||||
ports, leaving the real one restarting forever on "Address already in use".
|
||||
|
||||
Left empty, the units are merely refused to SYSTEM users (`ConditionUser=!@system`), so
|
||||
any normal login can still run the host by hand and root still cannot.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -466,6 +500,9 @@ in
|
||||
systemd.user.services.punktfunk-host = {
|
||||
description = "punktfunk GameStream + punktfunk/1 streaming host";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# Keep root (and every other system user) from starting a second host that steals the
|
||||
# fixed ports from the desktop user's — see `userScope`.
|
||||
unitConfig.ConditionUser = userScope;
|
||||
# Soft ordering: the host listens immediately and only touches the compositor per session.
|
||||
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
|
||||
wants = [ "pipewire.service" ];
|
||||
@@ -573,7 +610,12 @@ in
|
||||
systemd.user.services.punktfunk-web-init = {
|
||||
description = "punktfunk web console first-run setup (login password)";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
|
||||
# ⚠ ConditionUser here is TRIGGERING (`|`) and ConditionPathExists is not, so systemd
|
||||
# requires the path condition AND at least one user condition — which is the intent.
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!%h/.config/punktfunk/web-password";
|
||||
ConditionUser = userScope;
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
@@ -589,6 +631,8 @@ in
|
||||
systemd.user.services.punktfunk-web = {
|
||||
description = "punktfunk management web console";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# Same scoping as the host: root's instance would take 47992 from the real one.
|
||||
unitConfig.ConditionUser = userScope;
|
||||
after = [
|
||||
"punktfunk-web-init.service"
|
||||
"punktfunk-host.service"
|
||||
@@ -640,6 +684,8 @@ in
|
||||
systemd.user.services.punktfunk-scripting = {
|
||||
description = "punktfunk plugin/script runner";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# Same scoping as the host: a root-side runner would talk to the wrong session's mgmt API.
|
||||
unitConfig.ConditionUser = userScope;
|
||||
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
|
||||
# and retries per unit, so this is ordering only, not a hard requirement).
|
||||
after = [ "punktfunk-host.service" ];
|
||||
|
||||
@@ -140,8 +140,61 @@ fresh install uses the generated random console password — read it from
|
||||
> punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
|
||||
> + signing details.
|
||||
|
||||
## Installer signing (Azure Artifact Signing)
|
||||
|
||||
`setup.exe`, `punktfunk-host.exe`, `punktfunk-tray.exe` and the Vulkan HDR layer are signed with
|
||||
**Azure Artifact Signing** (formerly Trusted Signing): account `unomsigning`, certificate profile
|
||||
`unom-io`, endpoint `https://neu.codesigning.azure.net/`. It is a publicly trusted CA, so users get
|
||||
a named publisher in the UAC prompt and there is no `.cer` to import — `HOST_CER_PATH` is simply not
|
||||
emitted in this mode (every consumer already guards on `Test-Path`).
|
||||
|
||||
`pack-host-installer.ps1` resolves a backend in this order, first match wins:
|
||||
|
||||
| order | backend | selected by |
|
||||
| --- | --- | --- |
|
||||
| 1 | Azure Artifact Signing | `AZURE_CODESIGNING_ENDPOINT` + `_ACCOUNT` + `_PROFILE` all set |
|
||||
| 2 | stable self-signed `.pfx` | `MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` |
|
||||
| 3 | ephemeral self-signed | nothing set (canary / local only; a `v*` tag **fails closed**) |
|
||||
|
||||
Credentials for mode 1 come from the environment via `DefaultAzureCredential` — `AZURE_TENANT_ID`,
|
||||
`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, the `punktfunk-ci-signing` service principal. It holds
|
||||
exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the `unom-io` profile: it
|
||||
can sign and can do nothing else with the subscription. The script hard-fails if the trio is missing
|
||||
rather than letting `DefaultAzureCredential` fall through to an interactive login that would hang a
|
||||
runner forever.
|
||||
|
||||
> **Timestamping is mandatory here, not best-effort.** Azure mints a leaf certificate per request,
|
||||
> valid for about three days. An untimestamped signature therefore goes untrusted within days of
|
||||
> release — it would verify fine on the runner and fail on users' machines that weekend. `Sign-File`
|
||||
> refuses to retry without a timestamp in Azure mode; modes 2 and 3 keep the old lenient retry, where
|
||||
> the cert outlives the release anyway.
|
||||
|
||||
### Runner setup
|
||||
|
||||
`signtool` reaches Azure through `Azure.CodeSigning.Dlib.dll`, which ships in the
|
||||
`Microsoft.Trusted.Signing.Client` NuGet package — no installer, no fixed path. On the Windows runner:
|
||||
|
||||
```powershell
|
||||
nuget install Microsoft.Trusted.Signing.Client -OutputDirectory $env:USERPROFILE\.nuget\packages
|
||||
```
|
||||
|
||||
`Find-AzureDlib` searches that path and `C:\trusted-signing\`, newest first, so a package update needs
|
||||
no script edit. Set `AZURE_CODESIGNING_DLIB` to override with an explicit path.
|
||||
|
||||
## Driver signing (`DRIVER_CERT_PFX_B64`)
|
||||
|
||||
> **The drivers are deliberately NOT on Azure.** Their catalogs keep the self-signed
|
||||
> `CN=punktfunk-driver` cert below, which the installer still plants in the machine `Root` store.
|
||||
> The two signatures are independent by design — Windows verifies the installer via SmartScreen/UAC
|
||||
> and driver catalogs via PnP, and never requires a common signer, which is why the installer could
|
||||
> move to a public CA without touching the driver track at all.
|
||||
>
|
||||
> Worth revisiting: these are **user-mode** (UMDF) drivers and we already clear `FORCE_INTEGRITY`, so
|
||||
> a catalog signed by the publicly trusted Azure cert would likely chain to a root every Windows box
|
||||
> already has — which would let us drop the `Root` plant entirely and keep only the `TrustedPublisher`
|
||||
> entry that suppresses the device-software prompt. That is a real reduction in what we ask of a
|
||||
> user's machine, but it is **unverified**: test it on the Windows box before believing it.
|
||||
|
||||
Our three UMDF drivers are signed with a **stable self-signed code-signing cert**, subject
|
||||
`CN=punktfunk-driver`, supplied to `build-pf-vdisplay.ps1` / `build-gamepad-drivers.ps1` as the
|
||||
`DRIVER_CERT_PFX_B64` + `DRIVER_CERT_PASSWORD` Actions secrets. On a `v*` tag build a missing cert
|
||||
@@ -241,7 +294,8 @@ the recovery. From a Linux box drive either over SSH, e.g.
|
||||
# statically links the vendored VPL dispatcher — needs cmake + a libclang, no FFmpeg)
|
||||
cargo build --release -p punktfunk-host --features nvenc,qsv
|
||||
|
||||
# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
|
||||
# 2. pack (self-signed unless the AZURE_CODESIGNING_* trio or MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD
|
||||
# are set — see "Installer signing" above; -NoDriver to skip pf-vdisplay)
|
||||
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
|
||||
```
|
||||
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
|
||||
.DESCRIPTION
|
||||
From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this:
|
||||
1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed
|
||||
CN=unom - same scheme as the client's pack-msix.ps1) and exports the public .cer. The
|
||||
ephemeral fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign)
|
||||
is a hard failure, never a silent downgrade to a throwaway cert - see -RequireSignedCert,
|
||||
1. resolves a signing backend - Azure Artifact Signing (formerly Trusted Signing) when the
|
||||
AZURE_CODESIGNING_* trio is set, else a supplied stable .pfx from CI secrets, else an
|
||||
ephemeral self-signed CN=unom - same scheme as the client's pack-msix.ps1. The .pfx paths
|
||||
also export the public .cer; Azure does not (see below). The ephemeral fallback is for
|
||||
canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign) is a hard failure, never
|
||||
a silent downgrade to a throwaway cert - see -RequireSignedCert,
|
||||
2. signs the inner punktfunk-host.exe,
|
||||
3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver),
|
||||
4. runs ISCC to build punktfunk-host-setup-<ver>.exe,
|
||||
5. signs the setup.exe (timestamp best-effort),
|
||||
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step.
|
||||
5. signs the setup.exe (timestamped - MANDATORY under Azure signing, see Sign-File),
|
||||
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. Azure signing
|
||||
emits no .cer: the chain is publicly trusted, so there is nothing for a user to import.
|
||||
Every consumer of HOST_CER_PATH already guards on Test-Path, so it is simply absent.
|
||||
|
||||
NOTE the drivers are signed separately, by build-pf-vdisplay.ps1 / build-gamepad-drivers.ps1 with
|
||||
the DRIVER_CERT_* secret, and are NOT re-signed here (that would invalidate their catalogs). The
|
||||
installer's signature and the driver catalogs' signatures are independent by design - Windows
|
||||
verifies the first via SmartScreen/UAC and the second via PnP, and never requires a common signer.
|
||||
|
||||
Idempotent; safe to re-run. Run on the Windows runner / dev box (MSVC + Windows SDK + Inno Setup).
|
||||
|
||||
@@ -24,9 +33,20 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # e.g. 0.2.137 or 1.4.0 (free-form)
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release dir (has punktfunk-host.exe)
|
||||
[string]$OutDir = (Join-Path $TargetDir 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only. Azure signing carries its own subject
|
||||
# (the profile's verified CN/O), and nothing downstream of setup.exe compares the two - unlike
|
||||
# the MSIX, whose manifest Identity/@Publisher must match byte-for-byte. See pack-msix.ps1.
|
||||
[string]$Publisher = 'CN=unom',
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
# Azure Artifact Signing (formerly Trusted Signing). All three must be set to select it; it then
|
||||
# takes precedence over any .pfx. Credentials come from the environment via DefaultAzureCredential
|
||||
# (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET) - never passed as arguments, so they
|
||||
# cannot leak into a process listing or a transcript.
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
|
||||
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
|
||||
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
|
||||
[string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner
|
||||
@@ -70,6 +90,29 @@ function Find-SdkTool([string]$name) {
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has no
|
||||
# installer and no fixed location - hence an explicit override first, then the two paths the runner
|
||||
# setup uses (see packaging/windows/README.md). Newest version wins so a package update is picked up
|
||||
# without editing this script.
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
@@ -87,20 +130,43 @@ if ($NoSign -and $requireCert) {
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer"
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($PfxBase64) {
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
# signtool reads the account/profile from this file (/dmdf) rather than the command line.
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
Write-Host " dlib: $AzureDlib"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
|
||||
}
|
||||
elseif ($requireCert) {
|
||||
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 - refusing to fall back to " +
|
||||
"an ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD " +
|
||||
"repo secrets, or pass -RequireSignedCert false if this really is a test build.")
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
@@ -111,25 +177,44 @@ if (-not $NoSign) {
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
# Always export the public .cer. For a self-signed cert it's the file users import once
|
||||
# (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe; for a real CA
|
||||
# cert it's a harmless extra.
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
# Export the public .cer for the .pfx-backed modes. For a self-signed cert it's the file users
|
||||
# import once (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe.
|
||||
# Azure signing has no .pfx to read and needs no import - the chain is publicly trusted - so it
|
||||
# deliberately produces no .cer and HOST_CER_PATH stays unset.
|
||||
if ($signMode -ne 'azure') {
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
}
|
||||
}
|
||||
|
||||
# A timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
|
||||
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
|
||||
# signature stops verifying within days of shipping. Retrying without one there would produce an
|
||||
# artifact that passes on the runner and fails on every user's machine that weekend - so the
|
||||
# fallback is gated on the mode rather than applied blindly.
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes before they're packed -------------------------------------------------
|
||||
@@ -340,14 +425,18 @@ if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
# --- sign the setup.exe + clean up ------------------------------------------------------------
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if (-not $NoSign) {
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA - nothing for users to import."
|
||||
}
|
||||
elseif (-not $NoSign) {
|
||||
Write-Host "==> trust the cert once per machine (self-signed builds), then the signed setup.exe is trusted:"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPublisher"
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"HOST_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
if (-not $NoSign) { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
if (-not $NoSign -and $signMode -ne 'azure') { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
}
|
||||
|
||||
@@ -107,10 +107,10 @@ These manifests stay in winget-pkgs' own format rather than a bespoke one, so su
|
||||
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
|
||||
below, and `Agreements` being verified-developers-only in the community repo.
|
||||
|
||||
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
|
||||
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
|
||||
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
|
||||
> pre-existing condition rather than something winget introduces — but the community repo
|
||||
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
|
||||
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
|
||||
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
|
||||
> **Signing.** The installer is signed with **Azure Artifact Signing** (account `unomsigning`,
|
||||
> profile `unom-io`) — a publicly trusted CA, so there is no `.cer` for users to import. This
|
||||
> removed the blocker on submitting to the community repo (`microsoft/winget-pkgs`), whose
|
||||
> `Binary-Validation-Error` / `Validation-Defender-Error` checks require a publicly trusted cert;
|
||||
> the remaining upstream obstacle is `Agreements` being verified-developers-only. Note that a
|
||||
> trusted cert is not an instant SmartScreen bypass: reputation still accrues per publisher over
|
||||
> downloads, it just now accrues to a named identity instead of being permanently unknown.
|
||||
|
||||
@@ -16,12 +16,10 @@ winget upgrade unom.PunktfunkHost
|
||||
## Why self-hosted rather than the community repo
|
||||
|
||||
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
|
||||
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
|
||||
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
|
||||
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
|
||||
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
|
||||
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
|
||||
never accept.
|
||||
`Validation-Defender-Error` checks, which need a publicly trusted signing cert. That blocker is
|
||||
gone — the host installer is now signed with Azure Artifact Signing (see `packaging/windows/README.md`)
|
||||
— but a self-hosted source is still the right call: it has no such gate, can carry `Agreements`
|
||||
(verified-developers-only upstream), and can serve channels the community repo would never accept.
|
||||
|
||||
## What it implements
|
||||
|
||||
|
||||
@@ -113,4 +113,60 @@ $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH
|
||||
'@ | Set-Content -Encoding UTF8 $projectEnv
|
||||
info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up"
|
||||
|
||||
# --- Azure Artifact Signing (formerly Trusted Signing) toolchain, for the signing step in
|
||||
# windows-host.yml + windows-client.yml. Two pieces, neither of which the generic unom/infra image
|
||||
# carries, and both of which fail in ways that do not name themselves:
|
||||
#
|
||||
# 1. The .NET 8 runtime. Azure.CodeSigning.Dlib.dll is a mixed-mode (C++/CLI) assembly - it ships
|
||||
# Ijwhost.dll and a runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0 - so on a box with
|
||||
# no .NET runtime, signtool exits 3 having printed NOTHING AT ALL. Verified on .133 2026-08-14:
|
||||
# the box had pwsh 7 (self-contained, brings no shared runtime) and no dotnet whatsoever.
|
||||
# 2. The signing client, installed MACHINE-WIDE under C:\trusted-signing rather than into a user's
|
||||
# .nuget. The act_runner daemon runs as SYSTEM, whose USERPROFILE is
|
||||
# C:\Windows\System32\config\systemprofile - so a per-user install under Administrator is
|
||||
# invisible to every job that actually builds. Find-AzureDlib in both pack scripts searches
|
||||
# this exact path for that reason; verified by resolving it from a SYSTEM scheduled task.
|
||||
#
|
||||
# Both are SHA-256 pinned against version-immutable URLs (a nuget.org flat-container package and the
|
||||
# dotnet builds CDN are both immutable per version), so these fail closed on tampering rather than
|
||||
# every time Microsoft ships a patch release. Bump version + hash together to move either. ---
|
||||
$dotnetVer = '8.0.30'
|
||||
$dotnetSha = 'E40F199C6D5584AFF0554C01163C3C8D9CCF6BEC3A577E4D967E41070772A1C1'
|
||||
$tscVer = '1.0.95'
|
||||
$tscSha = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F'
|
||||
|
||||
if (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App') {
|
||||
info "shared .NET runtime already present ($((Get-ChildItem 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App' | ForEach-Object Name) -join ', '))"
|
||||
} else {
|
||||
info "installing .NET $dotnetVer runtime (required by Azure.CodeSigning.Dlib.dll)"
|
||||
$dn = "$env:TEMP\dotnet-runtime-$dotnetVer-win-x64.exe"
|
||||
Invoke-WebRequest -Uri "https://builds.dotnet.microsoft.com/dotnet/Runtime/$dotnetVer/dotnet-runtime-$dotnetVer-win-x64.exe" -OutFile $dn -UseBasicParsing
|
||||
$got = (Get-FileHash $dn -Algorithm SHA256).Hash
|
||||
if ($got -ne $dotnetSha) { Remove-Item $dn -Force; throw ".NET runtime download hash mismatch (got $got, pinned $dotnetSha)." }
|
||||
# -Wait is load-bearing: the bundle is a GUI PE that returns immediately when invoked with &,
|
||||
# leaving $LASTEXITCODE unset and racing any completion check against the install.
|
||||
$p = Start-Process -FilePath $dn -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
|
||||
Remove-Item $dn -Force -ErrorAction SilentlyContinue
|
||||
if ($p.ExitCode -ne 0) { throw ".NET runtime installer exited $($p.ExitCode)." }
|
||||
if (-not (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App')) { throw ".NET runtime installer reported success but installed no shared runtime." }
|
||||
}
|
||||
|
||||
$tscDir = "C:\trusted-signing\microsoft.trusted.signing.client\$tscVer"
|
||||
if (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll')) {
|
||||
info "Trusted Signing client $tscVer already present at $tscDir"
|
||||
} else {
|
||||
info "installing Microsoft.Trusted.Signing.Client $tscVer (machine-wide, for SYSTEM)"
|
||||
$nupkg = "$env:TEMP\microsoft.trusted.signing.client.$tscVer.nupkg"
|
||||
Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$tscVer/microsoft.trusted.signing.client.$tscVer.nupkg" -OutFile $nupkg -UseBasicParsing
|
||||
$got = (Get-FileHash $nupkg -Algorithm SHA256).Hash
|
||||
if ($got -ne $tscSha) { Remove-Item $nupkg -Force; throw "Trusted Signing client download hash mismatch (got $got, pinned $tscSha)." }
|
||||
if (Test-Path $tscDir) { Remove-Item -Recurse -Force $tscDir }
|
||||
New-Item -ItemType Directory -Force -Path $tscDir | Out-Null
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $tscDir)
|
||||
Remove-Item $nupkg -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $tscDir -Recurse -File | Unblock-File -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll'))) { throw "extracted $tscVer but bin\x64\Azure.CodeSigning.Dlib.dll is absent." }
|
||||
}
|
||||
|
||||
info "punktfunk extras provisioned OK."
|
||||
|
||||
Reference in New Issue
Block a user