Compare commits

...
7 Commits
Author SHA1 Message Date
enricobuehler a3e073fd3a Merge pull request 'The host-audio switch reaches every client, not just the desktop ones' (#423) from worktree-keep-host-audio-parity into main
ci / docs-drift (push) Successful in 28s
ci / bun-nix (push) Successful in 31s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 2m6s
deb / build-publish-gamescope (push) Successful in 52s
ci / rust-arm64 (push) Successful in 2m48s
deb / build-publish-client-arm64 (push) Successful in 1m18s
deb / build-publish (push) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m30s
arch / build-publish (push) Successful in 9m7s
android / android (push) Successful in 9m28s
ci / rust (push) Successful in 9m43s
decky / build-publish (push) Successful in 55s
deb / build-publish-host (push) Successful in 8m51s
flatpak / build-publish (push) Successful in 4m45s
deb / smoke-install (push) Successful in 2m21s
apple / distribute (push) Successful in 11m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m33s
apple / screenshots (push) Successful in 10m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m13s
windows-host / package (push) Successful in 14m54s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 50s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 1m14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / deploy-docs (push) Successful in 39s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 11s
2026-08-27 23:26:41 +00:00
enricobuehler 93f0739160 The new cap constant is a wire bit, not a knob, so it joins the baseline
ci / bun-nix (pull_request) Successful in 1m1s
ci / docs-drift (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m48s
ci / web (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m0s
android / android (pull_request) Successful in 6m43s
ci / rust (pull_request) Successful in 9m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m56s
`check-docs-drift.sh` matches on the `PUNKTFUNK_*` shape, which cannot tell an
environment variable from a cbindgen-exported ABI constant — so exporting
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO` read as a new undocumented knob and
turned the job red. Every sibling is already listed for exactly this reason:
the four other `CLIENT_CAP_*`, all eight `HOST_CAP_*`, all eight
`VIDEO_CAP_*`. This one goes beside them, in sort order.

It is not documentation debt being waved through. The bit's user-facing half
is the "Keep host audio playing" row, already written up in client-settings.md
and pointed at from the `PUNKTFUNK_AUDIO_OUTPUT_MODE` table; the embedder's
half is the doc comment cbindgen carries into the header. There is no
configuration surface here to document, because there is no variable.
2026-08-28 01:12:45 +02:00
enricobuehler 59bdef5230 The host-audio switch reaches every client, not just the desktop ones
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m15s
ci / docs-drift (pull_request) Failing after 1m17s
ci / docs-site (pull_request) Successful in 1m44s
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
android / android (pull_request) Canceled after 3m16s
ci / rust (pull_request) Canceled after 3m1s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 3m13s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
`keep_host_audio` shipped in 0.32.0 wired end to end — the setting, the
`CLIENT_CAP_KEEP_HOST_AUDIO` ask, the host's capture policy — but only three
surfaces ever offered it: the Linux GTK client, the Windows client, and the
desktop console. Apple and Android carried no reference to it at all, so the
one place a phone or an Apple TV would look for "why did my PC go quiet" had
nothing to find. There is no shared settings schema; each client hand-mirrors
`trust::Settings`, and this row simply never got mirrored.

Apple gains the full tier-P treatment: a `keepHostAudio` defaults key, the
`EffectiveSettings` field, the `StreamProfile` overlay (so a per-host profile
can keep the study PC's headphones live while the TV profile mutes them), the
Audio-section row, and the bit itself OR'd into the connect's `clientCaps`
beside the presenter caps. Android gains the same across `Settings`,
`Profiles` — including the `KNOWN` key set, without which the key reads as
unknown carry-through — the settings row, the console JSON both ways, and a
new `keepHostAudio` argument threaded through `nativeConnect` into the JNI
caps expression.

`pf-console-ui` had parked `RowId::KeepHostAudio` in `desktop_only` with a
comment saying "until the Android session advertises the bit". It does now, so
the gate comes off and the order-sensitive split test loses its entry.

The C ABI never exported the constant: `client_caps` is a passthrough byte, so
nothing needed a version bump, but cbindgen had no rename entry and emitted a
bare `CLIENT_CAP_KEEP_HOST_AUDIO` into every embedder's namespace — the exact
pollution R21 exists to prevent. It now carries the `PUNKTFUNK_` prefix its
`AUDIO_HIRES` sibling has, plus the compile-time drift assert.

Captions are the Linux client's wording verbatim, and the row sits between
audio quality and the microphone on all four clients, because a setting that
reads differently per platform is the same bug in a smaller font. The docs
line claiming "Linux, Windows and the desktop console" now says everywhere.

Verified: swift build + 384 Apple tests; `:app:compileDebugKotlin`;
`:kit:cargoNdkClippy` over pf-console-ui and the Android native crate; and all
205 pf-console-ui tests on Linux in a container, the split test among them.
2026-08-28 00:52:15 +02:00
enricobuehler 1391de87d6 Merge pull request 'A prep entry stops naming a device, and the docs show it' (#422) from worktree-prep-mode-env-example into main
ci / bun-nix (push) Successful in 36s
ci / docs-drift (push) Successful in 35s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
ci / docs-site (push) Successful in 1m47s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 38s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 34s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 26s
ci / rust-arm64 (push) Successful in 2m24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 35s
docker / builders-arm64cross (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m19s
docker / deploy-docs (push) Successful in 45s
ci / rust (push) Successful in 8m13s
Reviewed-on: #422
2026-08-27 22:50:03 +00:00
enricobuehler 3b0e880b4c docs(automation): a worked example for the prep mode env
ci / docs-drift (pull_request) Successful in 28s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 1m58s
ci / rust (pull_request) Successful in 7m36s
v0.32.0 gave per-app prep do/undo steps PF_STREAM_WIDTH/_HEIGHT/_REFRESH/_HDR
(hooks::prep_mode_env, both serving planes), but the docs' only example was a
one-liner. Add the entry+script pair that shows the point: the prep entry stops
naming a device and reads the negotiated mode instead - an FPS cap at the
client's own refresh, an HDR arm taken only when the session negotiated it, and
the raster for whatever wants pixels.

Written so the traps are visible rather than explained: undo sees the values its
do saw, HDR is 1/0 (the marker file's spelling) not PF_EVENT_*'s true/false, the
app identity is PF_APP_ID or PF_APP_TITLE by plane, and set -u makes an older
host fail the step loudly instead of capping at 'fps_limit='.

Verified: the fenced script extracted from the doc and run under sh - do writes
the cap and takes the HDR arm, undo restores and reverses it, an SDR session
skips HDR, an empty identity falls through to the literal, and an unset
environment exits non-zero.
2026-08-28 00:45:17 +02:00
enricobuehler 7a6b930867 Merge pull request 'The 0.32.0 security wording says what actually happened' (#421) from worktree-release-notes-severity into main
ci / web (push) Successful in 1m7s
ci / docs-drift (push) Successful in 26s
ci / rust-arm64 (push) Successful in 2m33s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 2m42s
ci / bun-nix (push) Successful in 2m1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 41s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders-arm64cross (push) Successful in 35s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m39s
docker / deploy-docs (push) Successful in 1m11s
ci / rust (push) Successful in 7m46s
Reviewed-on: #421
2026-08-27 21:24:50 +00:00
enricobuehler ababeafce3 docs(release): scope the 0.32.0 security wording to what it actually was
ci / bun-nix (pull_request) Successful in 36s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 1m44s
ci / rust-arm64 (pull_request) Successful in 2m14s
ci / docs-drift (pull_request) Successful in 2m15s
ci / rust (pull_request) Successful in 8m42s
Two overstatements in the v0.32.0 notes, both flagged on reading the published
release.

**"anyone who could open your web console page"** was simply wrong, and it is the
sentence the Discord embed leads with. Reaching the pairing routes required an
ALREADY-AUTHENTICATED console session — it was privilege escalation inside the
console (those routes alone among the trust-root actions never re-asked for the
console password), not something an unauthenticated peer on the network could
touch. The notes now say so plainly, and say why it still mattered: pairing is
what grants launch, so a console sign-in became the ability to start a process on
the host.

**"58 security issues"** invites the reading that there were 58 exploitable
defects. There were 58 findings, and the long tail is minor hardening and
build-infrastructure tightening. Both documents now lead with that proportion
instead of the raw count.

Same correction applied to CHANGELOG.md in three places: the section intro, the
`Breaking changes` entry, and the security-review heading.

Nothing about the fix itself changed — only the description of its severity. The
tag does not need re-pointing: `announce.yml` checks out the ref it is dispatched
on rather than the tag, and `apply_release_notes` re-syncs the body from
`docs/releases/<tag>.md` there, so this lands in the announcement. The live
release body is PATCHed to match in the same pass.

Gates: the ISO 24495 auditor is back to its 2 standing findings (NVIDIA and AMD,
company names deliberately left unexpanded); check-docs-links.sh and
check-docs-drift.sh both exit 0.
2026-08-27 23:22:27 +02:00
24 changed files with 210 additions and 26 deletions
+18 -11
View File
@@ -29,8 +29,9 @@ is content-identical to v0.31.4, the `info.version` stamp being the whole diff.
**Read `Breaking changes` regardless.** The auto-bitrate overhaul lands here in four phases, and
Phase 4 redefines what the bitrate number *means* on the control plane — deliberately without a
capability handshake. Alongside it: the 2026-08-25 security review (58 confirmed findings, one of
them a console session cookie reaching code execution), a second GameStream security pass (media-port
capability handshake. Alongside it: the 2026-08-25 security review (58 findings, most of them minor
hardening; the most serious let an **authenticated** console session reach the pairing routes without
the console password, and pairing is what grants launch), a second GameStream security pass (media-port
endpoint proof, control-stream nonce separation), and the GameStream competitive program, which turns
**both** video and control encryption on by default.
@@ -113,10 +114,14 @@ per-mode bpp pin and the ceiling, and `bitrate_auto` treats PyroWave sessions as
switch re-resolves the pin whatever the `Hello` carried.
**6. Console pairing routes move behind the console password** (security review). Arming, approving
and PIN submission rode the generic catch-all with the operator's admin bearer attached — a console
session cookie alone reached code execution. They now sit behind the console password like the other
trust-root routes, and the armed PIN is returned **once** in that gated response instead of riding a
1 s status poll. *A script driving the pairing routes with only a session cookie is now refused.*
and PIN submission rode the generic catch-all with the operator's admin bearer attached, so — alone
among the trust-root actions — they never re-asked for the console password. Scope it correctly: the
caller had to be an **authenticated console session** already, so this was privilege escalation
within the console, not something an unauthenticated network peer could reach. It matters because
pairing is what grants launch, so a console sign-in became the ability to start a process on the
host. They now sit behind the console password like the other trust-root routes, and the armed PIN is
returned **once** in that gated response instead of riding a 1 s status poll. *A script driving the
pairing routes with only a session cookie is now refused.*
**7. Deep links auto-dial by stable record id only.** A display name or an address gets a
confirmation on every client.
@@ -166,12 +171,14 @@ measured loss.
### Security review 2026-08-25
58 confirmed findings across host, console, clients and supply chain. Nearly every serious one is a
documented boundary whose code had stopped enforcing what its comment promised — so where the two
disagreed the comment won and the code was made to match, and where it could not be, the comment was
corrected instead.
58 findings across host, console, clients and supply chain. **Read that number for what it is:** the
long tail is minor hardening and build-infrastructure tightening, not 58 exploitable defects. What
the serious ones share is a shape — a documented boundary whose code had stopped enforcing what its
comment promised — so where the two disagreed the comment won and the code was made to match, and
where it could not be, the comment was corrected instead.
**Critical:** the console pairing routes, above. **High:** the plugin lane no longer reads the
**Most serious:** the console pairing routes, above — escalation from an already-authenticated
console session, not a pre-auth hole. **High:** the plugin lane no longer reads the
unredacted log ring (which carried the webhook credentials the `/hooks` carve-out exists to
withhold), and hook lines log an origin and a short id rather than a URL or a command line; a
plugin-reported pid is held to `procscan`'s start-time floor before the SYSTEM host will signal it;
@@ -101,6 +101,9 @@ suspend fun connectToHost(
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
// "Keep host audio playing": the host taps its own default output rather than
// silencing it for the session. Free to ask for — an older host just ignores it.
settings.keepHostAudio,
)
}
}
@@ -45,6 +45,7 @@ data class SettingsOverlay(
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val keepHostAudio: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
@@ -82,6 +83,7 @@ data class SettingsOverlay(
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
keepHostAudio = keepHostAudio ?: base.keepHostAudio,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
@@ -120,6 +122,8 @@ data class SettingsOverlay(
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
keepHostAudio =
if (after.keepHostAudio != before.keepHostAudio) after.keepHostAudio else keepHostAudio,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
@@ -152,6 +156,7 @@ data class SettingsOverlay(
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"keep_host_audio" -> copy(keepHostAudio = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
@@ -179,6 +184,7 @@ data class SettingsOverlay(
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (keepHostAudio != null) add("keep_host_audio")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
@@ -214,6 +220,7 @@ data class SettingsOverlay(
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
keepHostAudio?.let { j.put("keep_host_audio", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
@@ -236,6 +243,7 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
"keep_host_audio",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
@@ -255,6 +263,7 @@ data class SettingsOverlay(
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
keepHostAudio = j.optBooleanOrNull("keep_host_audio"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
@@ -91,6 +91,17 @@ data class Settings(
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* Ask the host to leave ITS OWN audio devices alone for this session
* (`CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback device already is,
* so the speakers or headphones on the host PC keep playing while this device hears the same
* audio. Off — the default, and what every build before this did — has the host park playback
* on a silent endpoint, which is why the host goes quiet the moment a stream starts.
*
* REQUEST-only: there is no host-cap echo, so an older host ignores the ask and re-routes as it
* always did ("audio still works, the host went quiet"), never a broken session.
*/
val keepHostAudio: Boolean = false,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -330,6 +341,7 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
keepHostAudio = prefs.getBoolean(K_KEEP_HOST_AUDIO, false),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -387,6 +399,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putBoolean(K_KEEP_HOST_AUDIO, s.keepHostAudio)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
@@ -426,6 +439,7 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_KEEP_HOST_AUDIO = "keep_host_audio"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
@@ -844,6 +844,13 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Keep host audio playing",
subtitle = "The host's speakers or headphones keep playing while you stream",
checked = s.keepHostAudio,
field = "keep_host_audio",
onCheckedChange = { on -> update(s.copy(keepHostAudio = on)) },
)
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -304,6 +304,7 @@ internal object ConsoleJson {
j.put("mouse_mode", s.mouseMode.storedName)
j.put("mic_enabled", s.micEnabled)
j.put("echo_cancel", s.echoCancel)
j.put("keep_host_audio", s.keepHostAudio)
j.put("audio_channels", s.audioChannels)
j.put("audio_format", s.audioFormat)
j.put("codec", s.codec)
@@ -361,6 +362,7 @@ internal object ConsoleJson {
?: s.mouseMode,
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
keepHostAudio = j.optBoolean("keep_host_audio", s.keepHostAudio),
audioChannels = j.optInt("audio_channels", s.audioChannels),
audioFormat = str("audio_format", s.audioFormat),
codec = str("codec", s.codec),
@@ -95,6 +95,11 @@ object NativeBridge {
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
/** Advertise `CLIENT_CAP_KEEP_HOST_AUDIO` ask the host to tap its default playback
* device instead of parking it on a silent endpoint, so the host PC's own speakers keep
* playing. REQUEST-only (no host-cap echo): an older host ignores it and goes quiet
* exactly as it always did. */
keepHostAudio: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -275,6 +275,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
keep_host_audio: jboolean,
) -> jlong {
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
@@ -438,6 +439,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}
// The user's "Keep host audio playing" setting: the host taps whatever its default
// playback device already is instead of parking the desktop mix on a silent
// endpoint, so the speakers on the host PC stay live. REQUEST-only — there is no
// host-cap echo — so an older host ignores the bit and re-routes exactly as it
// always did ("the host went quiet"), never a broken session.
| if keep_host_audio {
punktfunk_core::quic::CLIENT_CAP_KEEP_HOST_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
@@ -511,14 +511,20 @@ final class SessionModel: ObservableObject {
// the pointer and forwards shape/state, which StreamView draws as the real
// NSCursor. Capture-mode sessions keep today's composited pointer.
#if os(macOS)
let clientCaps: UInt8 =
let presentCaps: UInt8 =
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
let presentCaps: UInt8 = 0x02
#endif
// "Keep host audio playing": the host taps its default playback device instead of
// parking it on a silent endpoint, so the speakers on the host PC stay live. Pure
// REQUEST no host-cap echo so an older host simply goes quiet as it always did.
let clientCaps =
presentCaps
| (effective.keepHostAudio ? PunktfunkConnection.clientCapKeepHostAudio : 0)
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
@@ -106,6 +106,10 @@ enum SettingsFields {
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
overlay: \.echoCancel, effective: \.echoCancel)
}
static var keepHostAudio: SettingsField<Bool> {
.init(name: "keep_host_audio", key: DefaultsKey.keepHostAudio,
overlay: \.keepHostAudio, effective: \.keepHostAudio)
}
static var touchMode: SettingsField<String> {
.init(name: "touch_mode", key: DefaultsKey.touchMode,
overlay: \.touchMode, effective: \.touchMode)
@@ -201,6 +205,7 @@ extension SettingsView {
base.audioFormat = audioFormat
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.keepHostAudio = keepHostAudio
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
@@ -627,6 +627,11 @@ extension SettingsView {
}
}
}
described("The host's speakers or headphones keep playing while you stream — "
+ "needs a host on 0.32+",
field: "keep_host_audio") {
Toggle("Keep host audio playing", isOn: scoped(SettingsFields.keepHostAudio))
}
#if os(macOS)
// Which speaker THIS Mac plays through is this device's audio routing (tier G).
if !inProfileScope {
@@ -71,6 +71,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
@AppStorage(DefaultsKey.keepHostAudio) var keepHostAudio = false
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@AppStorage(DefaultsKey.audioFormat) var audioFormat = AudioFormatChoice.opus.rawValue
@AppStorage(DefaultsKey.codec) var codec = "auto"
@@ -1446,6 +1446,13 @@ public final class PunktfunkConnection {
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
/// `clientCaps` bit: ask the host to leave ITS OWN audio devices alone for this session
/// it taps whatever its default playback device already is instead of parking the desktop
/// mix on a silent endpoint, so the host PC's speakers keep playing and this device hears
/// the same audio. REQUEST-only, no host-cap echo: an older host ignores it and goes quiet
/// exactly as it always did, so it is safe to set unconditionally from the user's setting.
public static let clientCapKeepHostAudio: UInt8 = UInt8(PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO)
/// The `codec` SETTING (a `DefaultsKey.codec` / profile-overlay string) as a soft-preference
/// byte; `0` = Automatic, i.e. the host decides. Lives here beside the bits so the settings
/// string is mapped to the wire in exactly one place a session and a speed test that
@@ -89,6 +89,13 @@ public enum DefaultsKey {
/// speaker/mic or mic channel also bypasses it (the voice processor only follows the
/// system default devices) see SessionAudio's topology note.
public static let echoCancel = "punktfunk.echoCancel"
/// Ask the host to leave ITS OWN audio devices alone for this session
/// (`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback
/// device already is, so the speakers/headphones on the host PC keep playing while this
/// device hears the same audio. Off (the default) is today's behaviour the host parks
/// playback on a silent endpoint and goes quiet for the session. Best-effort: an older
/// host ignores the ask and re-routes as it always did.
public static let keepHostAudio = "punktfunk.keepHostAudio"
public static let speakerUID = "punktfunk.speakerUID"
public static let micUID = "punktfunk.micUID"
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
@@ -33,6 +33,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var audioFormat = AudioFormatChoice.opus.rawValue
public var micEnabled = true
public var echoCancel = true
public var keepHostAudio = false
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
@@ -101,6 +102,7 @@ public struct EffectiveSettings: Equatable, Sendable {
audioFormat = str(DefaultsKey.audioFormat, audioFormat)
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
keepHostAudio = bool(DefaultsKey.keepHostAudio, keepHostAudio)
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
@@ -183,6 +185,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.audioFormat { s.audioFormat = v }
if let v = overlay.micEnabled { s.micEnabled = v }
if let v = overlay.echoCancel { s.echoCancel = v }
if let v = overlay.keepHostAudio { s.keepHostAudio = v }
if let v = overlay.touchMode { s.touchMode = v }
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
@@ -115,6 +115,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var audioFormat: String?
public var micEnabled: Bool?
public var echoCancel: Bool?
public var keepHostAudio: Bool?
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
@@ -161,6 +162,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case audioFormat = "audio_format"
case micEnabled = "mic_enabled"
case echoCancel = "echo_cancel"
case keepHostAudio = "keep_host_audio"
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
@@ -199,6 +201,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
audioFormat = str(.audioFormat)
micEnabled = bool(.micEnabled)
echoCancel = bool(.echoCancel)
keepHostAudio = bool(.keepHostAudio)
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
@@ -239,6 +242,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(audioFormat, forKey: AnyKey(Key.audioFormat.rawValue))
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue))
try c.encodeIfPresent(keepHostAudio, forKey: AnyKey(Key.keepHostAudio.rawValue))
try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue))
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
@@ -297,6 +301,7 @@ public enum OverlayField {
case "audio_format": overlay.audioFormat = nil
case "mic_enabled": overlay.micEnabled = nil
case "echo_cancel": overlay.echoCancel = nil
case "keep_host_audio": overlay.keepHostAudio = nil
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
@@ -337,6 +342,7 @@ public enum OverlayField {
case "audio_format": return o.audioFormat != nil
case "mic_enabled": return o.micEnabled != nil
case "echo_cancel": return o.echoCancel != nil
case "keep_host_audio": return o.keepHostAudio != nil
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
+1 -5
View File
@@ -55,7 +55,7 @@ enum RowId {
/// see the `enabled` note in [`row_spec`].
AudioFormat,
/// The per-session `CLIENT_CAP_KEEP_HOST_AUDIO` ask — the host keeps playing on its own
/// output while it streams. Desktop-only until the Android session advertises the bit.
/// output while it streams. Every platform: the Android session advertises the bit too.
KeepHostAudio,
Mic,
EchoCancel,
@@ -990,9 +990,6 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
| RowId::AllowVrr
| RowId::Fullscreen
| RowId::Shortcuts
// Desktop-only until the Android session advertises CLIENT_CAP_KEEP_HOST_AUDIO —
// a row whose bit never goes out would be a dead toggle.
| RowId::KeepHostAudio
);
match platform {
Platform::Desktop => !android_only,
@@ -2665,7 +2662,6 @@ pub(super) mod tests {
RowId::TenBitSdr,
RowId::Vsync,
RowId::AllowVrr,
RowId::KeepHostAudio,
RowId::Shortcuts,
RowId::Fullscreen,
]
+1
View File
@@ -98,6 +98,7 @@ include = ["PunktfunkEndReason"]
# a benign redefinition in C. One name for one bit is the point — an embedder that reaches for
# either spelling gets the same constant.
"CLIENT_CAP_AUDIO_HIRES" = "PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES"
"CLIENT_CAP_KEEP_HOST_AUDIO" = "PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO"
"HOST_CAP_AUDIO_HIRES" = "PUNKTFUNK_HOST_CAP_AUDIO_HIRES"
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
+12
View File
@@ -1743,6 +1743,7 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_AUDIO_HIRES == crate::quic::HOST_CAP_AUDIO_HIRES);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES == crate::quic::CLIENT_CAP_AUDIO_HIRES);
assert!(PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO == crate::quic::CLIENT_CAP_KEEP_HOST_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
@@ -2567,6 +2568,17 @@ pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// 24-bit is where the plane earns its bandwidth. (Mirrors `quic::CLIENT_CAP_AUDIO_HIRES`.)
pub const PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES: u8 = 0x10;
/// [`punktfunk_connect_ex9`] `client_caps` bit: ask the host to leave its OWN audio devices
/// alone for this session — capture whatever the operator's default playback device already
/// is, instead of parking the desktop mix on a silent endpoint. The host keeps playing (the
/// headphones plugged into the host PC stay live) and this client hears the same audio:
/// Moonlight's "Mute host PC speakers" box, unchecked, per session.
///
/// REQUEST-only — there is no host-cap echo. An older host ignores the bit and re-routes as it
/// always did, which degrades to "audio still works, the host went quiet", so an embedder may
/// set it unconditionally from its user's setting. (Mirrors `quic::CLIENT_CAP_KEEP_HOST_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO: u8 = 0x20;
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
+64
View File
@@ -164,6 +164,70 @@ per-mode frame cap is one step for every device —
`{ "do": "rtss-cli property:set Global FramerateLimit $PF_STREAM_REFRESH" }` — instead of one
hard-coded entry per client.
### One entry, every client
The point of those four variables is that the *entry* stops describing a device. Attach one script
to the title and let the session tell it what it got — 60 Hz on the phone, 4K120 HDR on the TV, from
the same two lines:
```json
{ "id": 2, "title": "Cyberpunk 2077", "cmd": "steam -applaunch 1091500",
"prep": [
{ "do": "/home/me/.config/punktfunk/scripts/mode.sh do",
"undo": "/home/me/.config/punktfunk/scripts/mode.sh undo" }
] }
```
```sh
#!/bin/sh
# ~/.config/punktfunk/scripts/mode.sh — run as `mode.sh do` before the title, `mode.sh undo`
# at session end. Nothing here names a client: the negotiated mode arrives in the environment.
set -eu
CONF="${XDG_CONFIG_HOME:-$HOME/.config}/MangoHud/MangoHud.conf"
case "${1:-}" in
do)
# Cap the game at the refresh this client actually negotiated.
cp -f "$CONF" "$CONF.pf-bak"
printf 'fps_limit=%s\n' "$PF_STREAM_REFRESH" >>"$CONF"
# Light the panel's HDR only when the session really negotiated it.
if [ "$PF_STREAM_HDR" = 1 ]; then
kscreen-doctor output.HDMI-A-1.hdr.enable
fi
# The raster, for anything that wants pixels — a launcher's window size, a per-mode
# config profile, or just a line in the journal naming what launched.
logger -t punktfunk \
"prep ${PF_APP_ID:-${PF_APP_TITLE:-desktop}}: ${PF_STREAM_WIDTH}x${PF_STREAM_HEIGHT}@${PF_STREAM_REFRESH}"
;;
undo)
mv -f "$CONF.pf-bak" "$CONF"
if [ "$PF_STREAM_HDR" = 1 ]; then
kscreen-doctor output.HDMI-A-1.hdr.disable
fi
;;
esac
```
Four things that example is quietly relying on:
- **`undo` sees exactly what its `do` saw.** The values are captured once, at launch, and held for
the session — so teardown can branch on `PF_STREAM_HDR` and reach the same answer however the
stream ended.
- **`PF_STREAM_HDR` is `1`/`0`**, the stream-marker file's spelling, not the `true`/`false` that
`PF_EVENT_*` uses. One script can be written against either.
- **The app identity depends on the plane**: `PF_APP_ID` on a native client's launch,
`PF_APP_TITLE` from a Moonlight one. `${PF_APP_ID:-${PF_APP_TITLE:-desktop}}` reads whichever one
is set, and `:-` also catches the empty string a launch with no title of its own leaves behind.
- **`set -u` is doing work.** An older host doesn't set these, and the step then fails loudly (and
disarms its own `undo`) instead of silently capping the game at `fps_limit=`.
The same `prep` array works on a custom `library.json` entry, where the identity arrives as
`PF_APP_ID`. The console's Library form has no input for prep steps and **clears** them on save, so
edit that file directly.
## Reacting to a game, not a stream
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. Often the
+1 -2
View File
@@ -129,8 +129,7 @@ headphones plugged into the host keep playing, and both ends hear the same audio
host's headphones live while the TV profile mutes them. Best-effort: it needs a host on 0.32 or
newer, and with several clients streaming at once, any one asking wins for all of them. The
host-wide equivalent is
[`PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`](/docs/configuration). Linux, Windows and the
desktop console.
[`PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`](/docs/configuration). Offered everywhere.
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple
app.* Sends this device's microphone to the host's virtual mic. Spelled *Stream microphone* on
+7 -5
View File
@@ -4,13 +4,13 @@ The main change is that the bitrate number now tells the truth. When you set a r
The part of your client that picks the rate for you has also been rebuilt. It used to mistake a still picture for a bad connection. A paused game convinced it the link was failing, so the next moment of motion arrived at the worst quality available. Climbing back could take a minute and a half. It now tells stillness apart from congestion, and recovers in seconds.
This is also a security release. A review found 58 confirmed issues. One let anyone who could open your web console page run code on your host. Please update if anything other than you can reach your host.
It is also a security release, though the count sounds worse than it is. A review of the whole system produced 58 findings, and most are small: hardening, build infrastructure, and places where a comment promised more than the code delivered. The one worth your attention needed somebody already signed in to your web console. From there they could reach the pairing controls without being asked for the console password, and pairing a device lets it launch programs. If anyone else can sign in to your console, take this update.
## TL;DR
- **The bitrate you set is now the bitrate on the wire.** It used to cover the picture only. If you tuned your rate by trial and error, read "Before you update".
- **Automatic bitrate no longer misreads a still picture as a bad connection.** Motion starts sharp, and recovery takes seconds instead of 100.
- **Please update: 58 security issues are fixed.** One of them let a browser session on the web console reach code execution on your host.
- **A security review fixed 58 findings, most of them small.** The one that mattered needed somebody already signed in to your web console. From there they could pair a device without the console password, and pairing is what lets a device launch programs.
- **Android TV: fixed a permanent black screen with working sound.** Some devices could never start their decoder, so no picture ever arrived.
- **Two people streaming at once now both get a controller.** The second person used to get no controller on Windows, or a merged one on Linux.
- **Fixed garbage rectangles that never went away.** Black bars moved "like an equalizer" on every decoder, and nothing ever recovered.
@@ -30,7 +30,7 @@ This is also a security release. A review found 58 confirmed issues. One let any
- **Your bitrate setting means something slightly different now.** Nothing breaks, and you need do nothing. But the same number now puts less on your network than it did. If you tuned by trying numbers until streams behaved, you can ask for more than before. If you tuned *down* because streams were unstable, leave it where it is — the overshoot you were compensating for is gone.
- **Moonlight users: your video and control channel are now encrypted by default.** Clients that support it turn it on themselves. We watched one do exactly that on a local network, unprompted. That is why it became the default. A switch exists to go back; the developer changelog names it.
- **If you script the web console's pairing steps, they now need the console password.** Arming a pairing, approving it and submitting the PIN no longer accept a logged-in browser session alone. This is the critical security fix, so the change is deliberate. Add the password to your script.
- **If you script the web console's pairing steps, they now need the console password.** Arming a pairing, approving it and submitting the PIN no longer accept a signed-in browser session alone. That is the security fix described below, so the change is deliberate. Add the password to your script.
## New
@@ -76,9 +76,11 @@ This is also a security release. A review found 58 confirmed issues. One let any
## Security
A review on 2026-08-25 produced 58 confirmed findings. Almost every serious one followed the same pattern. A boundary was described correctly in its own documentation, and the code had drifted away from that promise.
A review on 2026-08-25 produced 58 findings across the host, the console, the apps and our build infrastructure. **Most are small** — tightening you would never notice, and places where a boundary was described correctly in its own documentation while the code had quietly drifted from that promise. A handful mattered. One mattered more than the rest, and it is worth being precise about what it was and was not.
**The critical finding was the web console's pairing steps.** They ran through a generic route carrying the operator's admin credentials. A browser session on the console — nothing more — could reach code execution on the host. Those steps now sit behind the console password. The PIN is handed over once in that protected response, instead of being published to anyone polling.
**That one was the web console's pairing controls.** Reaching them meant being signed in to the console already, so this was not something a stranger on your network could do. But once signed in, those controls ran through a general-purpose route carrying the operator's own credentials. So alone among the trust-root actions, they never asked for the console password. Since pairing a device is what lets that device launch programs, the gap turned "signed in to the console" into "can start something on the host".
**What changed:** arming a pairing, approving it and submitting the PIN now all sit behind that password. The PIN itself is handed over once in the protected response, rather than being readable by anything polling for status.
Other fixes in this release:
+12 -1
View File
@@ -482,6 +482,17 @@
// 24-bit is where the plane earns its bandwidth. (Mirrors `quic::CLIENT_CAP_AUDIO_HIRES`.)
#define PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES 16
// [`punktfunk_connect_ex9`] `client_caps` bit: ask the host to leave its OWN audio devices
// alone for this session — capture whatever the operator's default playback device already
// is, instead of parking the desktop mix on a silent endpoint. The host keeps playing (the
// headphones plugged into the host PC stay live) and this client hears the same audio:
// Moonlight's "Mute host PC speakers" box, unchecked, per session.
//
// REQUEST-only — there is no host-cap echo. An older host ignores the bit and re-routes as it
// always did, which degrades to "audio still works, the host went quiet", so an embedder may
// set it unconditionally from its user's setting. (Mirrors `quic::CLIENT_CAP_KEEP_HOST_AUDIO`.)
#define PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO 32
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
// datagram — an old host that sent no self-termination lease. The client then falls back to its
// own staleness heuristic for that update instead of a host-supplied deadline.
@@ -1100,7 +1111,7 @@
// asked wins for all of them until it ends. Composes with the host-wide
// `PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`, which is this behaviour for every session.
// `0x20` — `0x10` is [`CLIENT_CAP_AUDIO_HIRES`]; `0x40`/`0x80` remain free.
#define CLIENT_CAP_KEEP_HOST_AUDIO 32
#define PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -61,6 +61,7 @@ PUNKTFUNK_CLIENT_444
PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES
PUNKTFUNK_CLIENT_CAP_AUDIO_RED
PUNKTFUNK_CLIENT_CAP_CURSOR
PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO
PUNKTFUNK_CLIENT_CAP_PHASE_LOCK
PUNKTFUNK_CLIENT_CHACHA20
PUNKTFUNK_CLIENT_PEAK_NITS