Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e073fd3a | ||
|
|
93f0739160 | ||
|
|
59bdef5230 | ||
|
|
1391de87d6 | ||
|
|
3b0e880b4c | ||
|
|
7a6b930867 | ||
|
|
ababeafce3 |
+18
-11
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user