Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf4076eb7 | ||
|
|
53eb592c43 | ||
|
|
956d8dd8ef | ||
|
|
b2e716ad5f | ||
|
|
ec288d64d3 | ||
|
|
68353a5d57 | ||
|
|
ffd5a33598 | ||
|
|
4af8b02be1 | ||
|
|
b31495bea5 | ||
|
|
ee0b179618 | ||
|
|
d7e22c3db2 | ||
|
|
c1231fa2e6 | ||
|
|
1db7058a5d | ||
|
|
83a12c7413 | ||
|
|
7b1554af4b | ||
|
|
8f35155c14 | ||
|
|
0890cf3244 | ||
|
|
a9a514dea0 | ||
|
|
6e001e54b4 | ||
|
|
31b5f90b12 | ||
|
|
8abdd74a62 | ||
|
|
66a28d5abb | ||
|
|
e2faecfd42 | ||
|
|
76832a5b86 | ||
|
|
ec4bf75a6e |
@@ -160,6 +160,14 @@ jobs:
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
|
||||
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
|
||||
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
|
||||
# built module) and it is the only automated cover those behaviours have.
|
||||
- name: kit unit tests
|
||||
working-directory: clients/android
|
||||
run: ./gradlew :kit:testDebugUnitTest --stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
|
||||
@@ -483,6 +483,18 @@ private fun buildSettingsRows(
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture,
|
||||
) { update(s.copy(guideGesture = it)) },
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
|
||||
@@ -44,6 +44,8 @@ data class SettingsOverlay(
|
||||
val invertScroll: Boolean? = null,
|
||||
val gamepad: Int? = null,
|
||||
val gamepadForwarding: Boolean? = null,
|
||||
val systemButtons: String? = null,
|
||||
val guideGesture: String? = null,
|
||||
val statsVerbosity: StatsVerbosity? = null,
|
||||
/**
|
||||
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
|
||||
@@ -78,6 +80,8 @@ data class SettingsOverlay(
|
||||
invertScroll = invertScroll ?: base.invertScroll,
|
||||
gamepad = gamepad ?: base.gamepad,
|
||||
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
|
||||
systemButtons = systemButtons ?: base.systemButtons,
|
||||
guideGesture = guideGesture ?: base.guideGesture,
|
||||
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
|
||||
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
|
||||
presentPriority = presentPriority ?: base.presentPriority,
|
||||
@@ -115,6 +119,8 @@ data class SettingsOverlay(
|
||||
gamepadForwarding =
|
||||
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
|
||||
else gamepadForwarding,
|
||||
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
|
||||
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
|
||||
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
|
||||
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
|
||||
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
|
||||
@@ -142,6 +148,8 @@ data class SettingsOverlay(
|
||||
"invert_scroll" -> copy(invertScroll = null)
|
||||
"gamepad" -> copy(gamepad = null)
|
||||
"gamepad_forwarding" -> copy(gamepadForwarding = null)
|
||||
"system_buttons" -> copy(systemButtons = null)
|
||||
"guide_gesture" -> copy(guideGesture = null)
|
||||
"stats_verbosity" -> copy(statsVerbosity = null)
|
||||
"low_latency_mode" -> copy(lowLatencyMode = null)
|
||||
"present_priority" -> copy(presentPriority = null)
|
||||
@@ -166,6 +174,8 @@ data class SettingsOverlay(
|
||||
if (invertScroll != null) add("invert_scroll")
|
||||
if (gamepad != null) add("gamepad")
|
||||
if (gamepadForwarding != null) add("gamepad_forwarding")
|
||||
if (systemButtons != null) add("system_buttons")
|
||||
if (guideGesture != null) add("guide_gesture")
|
||||
if (statsVerbosity != null) add("stats_verbosity")
|
||||
if (lowLatencyMode != null) add("low_latency_mode")
|
||||
if (presentPriority != null) add("present_priority")
|
||||
@@ -198,6 +208,8 @@ data class SettingsOverlay(
|
||||
invertScroll?.let { j.put("invert_scroll", it) }
|
||||
gamepad?.let { j.put("gamepad", it) }
|
||||
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
|
||||
systemButtons?.let { j.put("system_buttons", it) }
|
||||
guideGesture?.let { j.put("guide_gesture", it) }
|
||||
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
|
||||
lowLatencyMode?.let { j.put("low_latency_mode", it) }
|
||||
presentPriority?.let { j.put("present_priority", it) }
|
||||
@@ -214,6 +226,7 @@ data class SettingsOverlay(
|
||||
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
|
||||
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
|
||||
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
|
||||
"system_buttons", "guide_gesture",
|
||||
"stats_verbosity",
|
||||
"low_latency_mode", "present_priority", "smooth_buffer",
|
||||
)
|
||||
@@ -237,6 +250,8 @@ data class SettingsOverlay(
|
||||
invertScroll = j.optBooleanOrNull("invert_scroll"),
|
||||
gamepad = j.optIntOrNull("gamepad"),
|
||||
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
|
||||
systemButtons = j.optStringOrNull("system_buttons"),
|
||||
guideGesture = j.optStringOrNull("guide_gesture"),
|
||||
statsVerbosity = j.optStringOrNull("stats_verbosity")
|
||||
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
|
||||
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
|
||||
|
||||
@@ -45,6 +45,20 @@ data class Settings(
|
||||
* bind — which is why it gates the USB capture paths, not just the wire sends.
|
||||
*/
|
||||
val gamepadForwarding: Boolean = true,
|
||||
/**
|
||||
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
|
||||
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
|
||||
* the app on most devices) | `"forward"` | `"local"`.
|
||||
*/
|
||||
val systemButtons: String = "auto",
|
||||
/**
|
||||
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
|
||||
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
|
||||
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
|
||||
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
|
||||
* shell intercepts the physical guide button.
|
||||
*/
|
||||
val guideGesture: String = "auto",
|
||||
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
* can capture; the resolved count drives the decoder + AAudio layout. */
|
||||
val audioChannels: Int = 2,
|
||||
@@ -228,6 +242,8 @@ class SettingsStore(context: Context) {
|
||||
compositor = prefs.getInt(K_COMPOSITOR, 0),
|
||||
gamepad = prefs.getInt(K_GAMEPAD, 0),
|
||||
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
|
||||
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
|
||||
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
|
||||
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
|
||||
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
|
||||
micEnabled = prefs.getBoolean(K_MIC, false),
|
||||
@@ -275,6 +291,8 @@ class SettingsStore(context: Context) {
|
||||
.putInt(K_COMPOSITOR, s.compositor)
|
||||
.putInt(K_GAMEPAD, s.gamepad)
|
||||
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
|
||||
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
|
||||
.putString(K_GUIDE_GESTURE, s.guideGesture)
|
||||
.putInt(K_AUDIO_CH, s.audioChannels)
|
||||
.putString(K_CODEC, s.codec)
|
||||
.putBoolean(K_MIC, s.micEnabled)
|
||||
@@ -305,6 +323,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_COMPOSITOR = "compositor"
|
||||
const val K_GAMEPAD = "gamepad"
|
||||
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
|
||||
const val K_SYSTEM_BUTTONS = "system_buttons"
|
||||
const val K_GUIDE_GESTURE = "guide_gesture"
|
||||
const val K_AUDIO_CH = "audio_channels"
|
||||
const val K_CODEC = "codec"
|
||||
const val K_MIC = "mic_enabled"
|
||||
@@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
|
||||
* Android — the press reaches the app on most devices, and where the shell shows its own UI
|
||||
* for it that's the shell's business. */
|
||||
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
|
||||
|
||||
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
|
||||
* host); "on" is for devices whose shell intercepts the physical guide button. */
|
||||
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
|
||||
|
||||
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
|
||||
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
|
||||
fun Settings.preferredCodec(): Int = when (codec) {
|
||||
@@ -621,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
|
||||
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
|
||||
)
|
||||
|
||||
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
|
||||
val SYSTEM_BUTTON_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"forward" to "Send to host",
|
||||
"local" to "This device",
|
||||
)
|
||||
|
||||
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
|
||||
val GUIDE_GESTURE_OPTIONS = listOf(
|
||||
"auto" to "Automatic",
|
||||
"on" to "On",
|
||||
"off" to "Off",
|
||||
)
|
||||
|
||||
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
caption = "The virtual pad the host creates. Automatic matches your controller; " +
|
||||
"every connected one is forwarded as its own player.",
|
||||
) { g -> update(s.copy(gamepad = g)) }
|
||||
SettingDropdown(
|
||||
label = "Guide button",
|
||||
options = SYSTEM_BUTTON_OPTIONS,
|
||||
selected = s.systemButtons,
|
||||
field = "system_buttons",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
|
||||
"Automatic sends them to the host whenever this device delivers them.",
|
||||
) { v -> update(s.copy(systemButtons = v)) }
|
||||
SettingDropdown(
|
||||
label = "Hold Select for guide",
|
||||
options = GUIDE_GESTURE_OPTIONS,
|
||||
selected = s.guideGesture,
|
||||
field = "guide_gesture",
|
||||
enabled = s.gamepadForwarding,
|
||||
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
|
||||
"slightly delayed. For devices that intercept the real guide button.",
|
||||
) { v -> update(s.copy(guideGesture = v)) }
|
||||
DeviceScopeOnly {
|
||||
ClickableRow(
|
||||
title = "Connected controllers",
|
||||
|
||||
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(
|
||||
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
|
||||
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
|
||||
)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
|
||||
@@ -116,7 +116,12 @@ class DsCapture(
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
// Nothing can retry after this point, so a failure is worth saying out loud: it is
|
||||
// the difference between a quiet pad and one that buzzes until it is unplugged.
|
||||
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
|
||||
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
|
||||
// triggers the game was holding, which outlive the link just as stubbornly.
|
||||
resetRichFeedback(m)
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
@@ -145,6 +150,9 @@ class DsCapture(
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
// Release the transport too: the link only *signals* the drop, so without this an unplug
|
||||
// left its connection open, its interfaces claimed and its detach receiver registered.
|
||||
usb.stop()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
@@ -216,17 +224,20 @@ class DsCapture(
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
val stop = low == 0 && high == 0
|
||||
if (!stop) armBackstop(backstopMs)
|
||||
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
|
||||
}
|
||||
if (stop) {
|
||||
// Disarm only once the stop is actually on its way. Dropping the net *before* the
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +263,9 @@ class DsCapture(
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
|
||||
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
|
||||
// collapsing a backlog of them down to the last.
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
@@ -261,8 +275,38 @@ class DsCapture(
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
OutReportQueue.KEY_RUMBLE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
|
||||
*
|
||||
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
|
||||
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
|
||||
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
|
||||
* nothing to release it but another game that happens to set one.
|
||||
*
|
||||
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
|
||||
* queue would never drain. Writes are best-effort — the pad may already be gone.
|
||||
*/
|
||||
private fun resetRichFeedback(m: DsDevice.Model) {
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
|
||||
// blacking the lightbar is a single composed report.
|
||||
ds4Rgb = 0
|
||||
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
|
||||
return
|
||||
}
|
||||
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
|
||||
for (which in 0..1) {
|
||||
usb.writeControl(
|
||||
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
|
||||
)
|
||||
}
|
||||
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
|
||||
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
|
||||
}
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
@@ -284,7 +328,12 @@ class DsCapture(
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
val m = model ?: return@Runnable
|
||||
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
|
||||
// than give up: this is the last thing between a stalled poll thread and a pad that
|
||||
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
|
||||
// clears `model` and disarms.
|
||||
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
@@ -297,5 +346,9 @@ class DsCapture(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
|
||||
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
|
||||
* and the host has already moved on, so nothing else is coming to silence them. */
|
||||
const val STOP_RETRY_MS = 100L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class GamepadFeedback(
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
const val TAG_HID_RAW: Byte = 0x05
|
||||
|
||||
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
|
||||
const val LOG_EVERY = 128L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
@@ -125,6 +128,7 @@ class GamepadFeedback(
|
||||
fun start() {
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
@@ -136,26 +140,50 @@ class GamepadFeedback(
|
||||
// the backstop (the hardware net under a stalled poll thread).
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
// Rendering is binder calls into the vibrator service, and every one of them can
|
||||
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
|
||||
// the ordinary RuntimeException a dying service wraps its RemoteException in.
|
||||
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
|
||||
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
|
||||
// the session. Losing a single command is recoverable; losing the loop is not.
|
||||
runCatching {
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
backstopMs,
|
||||
)
|
||||
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
|
||||
val buf = ByteBuffer.allocateDirect(128)
|
||||
var failures = 0L
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
|
||||
// calls, and an unchecked throw here would silently end the rich-feedback plane.
|
||||
runCatching { dispatchHidout(buf, n) }
|
||||
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
|
||||
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
|
||||
* command, which at a rumble plane's rate would bury the log.
|
||||
*/
|
||||
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
|
||||
if (seen == 0L || seen % LOG_EVERY == 0L) {
|
||||
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
|
||||
}
|
||||
return seen + 1
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
@@ -269,7 +297,7 @@ class GamepadFeedback(
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
runCatching { m.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
@@ -294,7 +322,7 @@ class GamepadFeedback(
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
runCatching { lv.cancel() } // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -50,12 +50,35 @@ class GamepadRouter(
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
|
||||
* with this device.
|
||||
*/
|
||||
private val systemForward: Boolean = true,
|
||||
/**
|
||||
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
|
||||
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
|
||||
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
|
||||
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
|
||||
* while other buttons are down passes through untouched, so the exit/mic chords keep
|
||||
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
|
||||
*/
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
|
||||
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
|
||||
// timer / a delivered tap's owed release (both on the main handler), and whether
|
||||
// the held Select was transformed into a synthetic guide.
|
||||
var pendingGuide: Runnable? = null
|
||||
var pendingTapUp: Runnable? = null
|
||||
var selectAsGuide = false
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
@@ -139,7 +162,24 @@ class GamepadRouter(
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
// tracking, symmetric on both edges so nothing leaks into the chords either.
|
||||
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
|
||||
if (down) {
|
||||
if (guideGesture && send) {
|
||||
// A Select pressed ALONE is held back until it resolves: a tap (delivered
|
||||
// on release), a combo member (the next button flushes it as a real
|
||||
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
|
||||
// it either way, so the exit/mic chords read as if the gesture didn't
|
||||
// exist (Select+Y still fires the mic toggle: the flush sends Select's
|
||||
// down before Y's).
|
||||
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
|
||||
slot.held = slot.held or bit
|
||||
armGuide(slot)
|
||||
return
|
||||
}
|
||||
flushPendingSelect(slot)
|
||||
}
|
||||
if (send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
}
|
||||
@@ -155,7 +195,8 @@ class GamepadRouter(
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
if (send && forwarding) {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
}
|
||||
slot.held = slot.held and bit.inv()
|
||||
@@ -167,6 +208,61 @@ class GamepadRouter(
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
|
||||
private fun armGuide(slot: Slot) {
|
||||
val r = Runnable {
|
||||
slot.pendingGuide = null
|
||||
slot.selectAsGuide = true
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
|
||||
}
|
||||
}
|
||||
slot.pendingGuide = r
|
||||
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second button joined while Select was pending — it was a real Select after all; its
|
||||
* deferred down goes out before the caller sends the new button's, preserving chronology.
|
||||
*/
|
||||
private fun flushPendingSelect(slot: Slot) {
|
||||
val r = slot.pendingGuide ?: return
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select released with gesture state outstanding — true when the gesture owned the
|
||||
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
|
||||
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
|
||||
* can fold into nothing in the host's per-pad input fold).
|
||||
*/
|
||||
private fun consumeSelectRelease(slot: Slot): Boolean {
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
return true
|
||||
}
|
||||
val r = slot.pendingGuide ?: return false
|
||||
mainHandler.removeCallbacks(r)
|
||||
slot.pendingGuide = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
|
||||
val up = Runnable {
|
||||
slot.pendingTapUp = null
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
slot.pendingTapUp = up
|
||||
mainHandler.postDelayed(up, TAP_PRESS_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
|
||||
private fun armExit() {
|
||||
if (pendingExit != null) return // already counting down
|
||||
@@ -362,6 +458,24 @@ class GamepadRouter(
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
|
||||
// release goes out NOW (its down is already on the wire and the handle may not
|
||||
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
|
||||
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
|
||||
slot.pendingGuide = null
|
||||
slot.pendingTapUp?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
slot.pendingTapUp = null
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
|
||||
}
|
||||
}
|
||||
if (slot.selectAsGuide) {
|
||||
slot.selectAsGuide = false
|
||||
if (forwarding) {
|
||||
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
|
||||
}
|
||||
}
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
@@ -403,5 +517,14 @@ class GamepadRouter(
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
|
||||
const val GUIDE_HOLD_MS = 350L
|
||||
|
||||
/**
|
||||
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
|
||||
* this much, so the pair can't coalesce into no press at all.
|
||||
*/
|
||||
const val TAP_PRESS_MS = 50L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
@@ -81,14 +81,20 @@ class HidUsbLink(
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
|
||||
* what gets discarded when it fills, and why that is not simply "the oldest". */
|
||||
private val outQueue = OutReportQueue()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
|
||||
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
|
||||
* it. Reset by [start]. */
|
||||
private val down = AtomicBoolean(false)
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
@@ -114,6 +120,7 @@ class HidUsbLink(
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
down.set(false)
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
@@ -134,10 +141,7 @@ class HidUsbLink(
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +225,9 @@ class HidUsbLink(
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
// `start` already returned true, so without this the owner would sit waiting on a
|
||||
// capture that never streams and never reports itself dead.
|
||||
linkDown()
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
@@ -295,10 +302,23 @@ class HidUsbLink(
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
linkDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the link down, exactly once, from whichever detector noticed first — the detach
|
||||
* broadcast (main thread) or the reader thread on its way out.
|
||||
*
|
||||
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
|
||||
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
|
||||
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
|
||||
* the interfaces claimed (the pad could not return to Android's own input stack) and the
|
||||
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
|
||||
* that stayed live for the process's lifetime.
|
||||
*/
|
||||
private fun linkDown() {
|
||||
running = false
|
||||
if (down.compareAndSet(false, true)) onClosed()
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
@@ -314,28 +334,35 @@ class HidUsbLink(
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*
|
||||
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
|
||||
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
|
||||
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
|
||||
*
|
||||
* Returns whether the report reached the device or is queued for it. A caller that is writing
|
||||
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
|
||||
* for one that landed.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
|
||||
if (data.isEmpty()) return false
|
||||
return when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
|
||||
outQueue.offer(data, coalesce)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
private fun setReport(type: Int, data: ByteArray): Boolean {
|
||||
val conn = connection ?: return false
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
|
||||
return sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,9 +371,8 @@ class HidUsbLink(
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
fun writeControl(data: ByteArray): Boolean =
|
||||
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
@@ -358,27 +384,48 @@ class HidUsbLink(
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
private fun sendReport(
|
||||
conn: UsbDeviceConnection,
|
||||
ifaceId: Int,
|
||||
type: Int,
|
||||
data: ByteArray,
|
||||
): Boolean {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
// controlTransfer returns the byte count, or a negative value on failure — a failed write
|
||||
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
|
||||
val n = runCatching {
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}.getOrDefault(-1)
|
||||
return n >= 0
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
/**
|
||||
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
|
||||
*
|
||||
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
|
||||
* and it arrives on the reader thread, which must not try to join itself.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
|
||||
down.set(true)
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
if (reader !== Thread.currentThread()) {
|
||||
runCatching { reader?.join(1000) }
|
||||
// Only forget the thread once it is actually gone: clearing it while it still runs
|
||||
// would let a later stop() skip the join and free the connection under it.
|
||||
reader = null
|
||||
}
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
|
||||
* policy knows which reports may be thrown away and which may not.
|
||||
*
|
||||
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
|
||||
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
|
||||
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
|
||||
* question is what to discard when it fills.
|
||||
*
|
||||
* The old policy was "newest wins": drop from the head until there is room. That is right for
|
||||
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
|
||||
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
|
||||
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
|
||||
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
|
||||
* time that value happens to change, which may be never.
|
||||
*
|
||||
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
|
||||
* That distinction cannot be recovered from the report itself: every DualSense output report
|
||||
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
|
||||
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
|
||||
*
|
||||
* Two rules:
|
||||
* - A report offered with a coalescing key **replaces** the pending report with that key, in
|
||||
* place. A burst of rumble collapses to its latest value and never displaces anything else.
|
||||
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
|
||||
* report goes first. A one-shot is discarded only if the queue is full of nothing but
|
||||
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
|
||||
* produces.
|
||||
*
|
||||
* Thread-safe: offered by the feedback threads, drained by the reader thread.
|
||||
*/
|
||||
internal class OutReportQueue(private val cap: Int = CAP) {
|
||||
private class Entry(val key: Int, val data: ByteArray)
|
||||
|
||||
private val items = ArrayDeque<Entry>()
|
||||
|
||||
/**
|
||||
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
|
||||
* constant identifying a level-styled stream whose newer values supersede older ones.
|
||||
*
|
||||
* Returns false only if the report had to be dropped outright — the caller can then treat the
|
||||
* write as failed rather than assuming it is on its way.
|
||||
*/
|
||||
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
|
||||
if (key != NO_COALESCE) {
|
||||
val at = items.indexOfFirst { it.key == key }
|
||||
if (at >= 0) {
|
||||
// Supersede in place: keeping the queue position stops a fast rumble stream from
|
||||
// repeatedly jumping the one-shots queued ahead of it.
|
||||
items[at] = Entry(key, data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (items.size >= cap) {
|
||||
val victim = items.indexOfFirst { it.key != NO_COALESCE }
|
||||
if (victim >= 0) {
|
||||
items.removeAt(victim)
|
||||
} else if (key != NO_COALESCE) {
|
||||
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
|
||||
// rather than a one-shot that will never come again.
|
||||
return false
|
||||
} else {
|
||||
items.removeFirst()
|
||||
}
|
||||
}
|
||||
items.addLast(Entry(key, data))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next report to submit, or null when nothing is pending. */
|
||||
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
|
||||
|
||||
fun clear() = synchronized(items) { items.clear() }
|
||||
|
||||
val size: Int get() = synchronized(items) { items.size }
|
||||
|
||||
companion object {
|
||||
/** This report is a one-shot: never superseded, evicted only as a last resort. */
|
||||
const val NO_COALESCE = 0
|
||||
|
||||
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
|
||||
const val KEY_RUMBLE = 1
|
||||
|
||||
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
|
||||
const val CAP = 32
|
||||
}
|
||||
}
|
||||
@@ -273,10 +273,20 @@ class Sc2Capture(
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "SC2 link closed (unplug / power-off)")
|
||||
// Both transports share this callback, so read which one was live BEFORE clearing it —
|
||||
// releasing the other would tear down a link that never dropped.
|
||||
val dropped = activeLink
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
releaseUiKeys()
|
||||
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
|
||||
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
|
||||
// round-trip a link many times in one session.
|
||||
when (dropped) {
|
||||
LINK_USB -> usb.stop()
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
|
||||
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
|
||||
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
|
||||
*/
|
||||
class OutReportQueueTest {
|
||||
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
|
||||
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
|
||||
|
||||
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
|
||||
private fun drain(q: OutReportQueue): List<Int> =
|
||||
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
|
||||
|
||||
@Test
|
||||
fun `rumble supersedes the pending rumble instead of queueing another`() {
|
||||
val q = OutReportQueue()
|
||||
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
|
||||
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
|
||||
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
|
||||
assertArrayEquals(report(3), q.poll())
|
||||
assertNull(q.poll())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10)) // a one-shot queued behind it
|
||||
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
|
||||
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
|
||||
// behind an endlessly-renewed entry.
|
||||
assertEquals(listOf(2, 10), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue sacrifices rumble, never a one-shot`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
q.offer(report(12))
|
||||
assertEquals(4, q.size)
|
||||
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
|
||||
// ordering; what matters is that the one-shots all survive.
|
||||
assertTrue(q.offer(report(13)))
|
||||
assertEquals(listOf(10, 11, 12, 13), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the one-shot the host never repeats survives a rumble storm`() {
|
||||
val q = OutReportQueue(cap = 4)
|
||||
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
|
||||
// old newest-wins eviction the colour was dropped from the head and never came back,
|
||||
// leaving the pad lit wrong until the value next happened to change.
|
||||
q.offer(report(200)) // lightbar
|
||||
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
|
||||
val out = drain(q)
|
||||
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
|
||||
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertFalse(
|
||||
"with nothing coalescable to sacrifice, the replaceable report yields",
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
|
||||
)
|
||||
assertEquals(listOf(10, 11), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
|
||||
val q = OutReportQueue(cap = 2)
|
||||
q.offer(report(10))
|
||||
q.offer(report(11))
|
||||
assertTrue(q.offer(report(12)))
|
||||
assertEquals(listOf(11, 12), drain(q))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the queue`() {
|
||||
val q = OutReportQueue()
|
||||
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
|
||||
q.offer(report(10))
|
||||
q.clear()
|
||||
assertEquals(0, q.size)
|
||||
assertNull(q.poll())
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
||||
host's virtual pad.
|
||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
||||
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
|
||||
every main push. See the script header for details.
|
||||
every main push. See the script header for details. The script's `SCENES` array is the listing
|
||||
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
|
||||
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
|
||||
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
|
||||
(a stranger's hostname reached the live listing that way once).
|
||||
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||
`apple-stage2-presenter.md`).
|
||||
|
||||
|
||||
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) {
|
||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
}
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardSpacing: CGFloat {
|
||||
|
||||
@@ -24,6 +24,13 @@ import ImageIO
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// This process was launched to capture a screenshot. Cheap enough to consult from the
|
||||
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
|
||||
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
|
||||
static var isActive: Bool {
|
||||
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
|
||||
}
|
||||
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed —
|
||||
// the streamed frame itself — ignore it themselves.
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
@@ -129,18 +139,64 @@ enum MacSelfCapture {
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
|
||||
/// Requires the app to allow those orientations in Info.plist — it does, for both.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
func makeUIViewController(context: Context) -> ShotOrientationController {
|
||||
ShotOrientationController(mask: mask)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
|
||||
vc.mask = mask
|
||||
vc.applyGeometry()
|
||||
}
|
||||
|
||||
private var mask: UIInterfaceOrientationMask {
|
||||
orientation == .landscape ? .landscapeRight : .portrait
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the window scene to rotate, from a place where there IS a window.
|
||||
///
|
||||
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
|
||||
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
|
||||
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
|
||||
/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured
|
||||
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
|
||||
/// keeps the scene from rotating back if the simulator reports a device orientation change.
|
||||
final class ShotOrientationController: UIViewController {
|
||||
var mask: UIInterfaceOrientationMask
|
||||
|
||||
init(mask: UIInterfaceOrientationMask) {
|
||||
self.mask = mask
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
applyGeometry()
|
||||
}
|
||||
|
||||
func applyGeometry() {
|
||||
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
|
||||
// which still runs before this controller is in a window.
|
||||
let scene = view.window?.windowScene
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
guard let scene else { return }
|
||||
// Report a refusal instead of silently shipping the wrong orientation — that is exactly
|
||||
// how every landscape scene went out as a portrait PNG for as long as it did.
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
|
||||
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
|
||||
fflush(stdout)
|
||||
}
|
||||
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -81,24 +81,126 @@ enum ShotScenes {
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
// Stable ids so the store, the adverts and the profile bindings all point at the same things
|
||||
// across every scene and every run.
|
||||
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
|
||||
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
|
||||
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
|
||||
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
|
||||
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
|
||||
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
|
||||
|
||||
static let hdrProfileID = "a71c4e0d9f22"
|
||||
static let couchProfileID = "3e88b107c4da"
|
||||
|
||||
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
|
||||
/// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is
|
||||
/// suppressed, so this never reaches a real user's catalog.
|
||||
static func installProfiles() {
|
||||
guard !profilesInstalled else { return }
|
||||
profilesInstalled = true
|
||||
ProfileStore.shared.debugSet([
|
||||
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
|
||||
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
|
||||
])
|
||||
}
|
||||
|
||||
private static var profilesInstalled = false
|
||||
|
||||
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
|
||||
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
|
||||
/// chains give every tile its real vendor mark instead of a letter monogram.
|
||||
///
|
||||
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
|
||||
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
|
||||
/// listing. The binding chip carries the profile story on its own.
|
||||
static func hostStore() -> HostStore {
|
||||
installProfiles()
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
|
||||
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
|
||||
osChain: "windows/11"),
|
||||
StoredHost(
|
||||
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
|
||||
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
|
||||
StoredHost(
|
||||
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
|
||||
profileID: couchProfileID, osChain: "linux/ubuntu"),
|
||||
StoredHost(
|
||||
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
|
||||
osChain: "linux/arch"),
|
||||
StoredHost(
|
||||
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
|
||||
osChain: "linux/nobara"),
|
||||
StoredHost(
|
||||
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
|
||||
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
|
||||
osChain: "windows/11"),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
|
||||
/// through the real `advertises` path, and the reachability probe skips them — no network from
|
||||
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
|
||||
/// new host populates the "On this network" section.
|
||||
///
|
||||
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
|
||||
/// machine's LAN into the App Store listing.
|
||||
static func discovery() -> HostDiscovery {
|
||||
let discovery = HostDiscovery()
|
||||
discovery.debugSet([
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
|
||||
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
|
||||
osChain: "windows/11"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
|
||||
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
|
||||
osChain: "linux/fedora/bazzite"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
|
||||
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
|
||||
HostDiscovery.debugAdvert(
|
||||
id: "studio", name: "Studio PC", host: "192.168.1.58",
|
||||
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
|
||||
osChain: "windows/11"),
|
||||
])
|
||||
return discovery
|
||||
}
|
||||
|
||||
static let host = StoredHost(
|
||||
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, osChain: "windows/11")
|
||||
|
||||
/// What the pairing sheet calls THIS device. Taken from the platform, not from
|
||||
/// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name
|
||||
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
|
||||
static var clientDeviceName: String {
|
||||
#if os(tvOS)
|
||||
"Apple TV"
|
||||
#elseif os(macOS)
|
||||
"MacBook Pro"
|
||||
#else
|
||||
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
static let fingerprint = hostFingerprint(0)
|
||||
|
||||
/// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one
|
||||
/// across the mock grid made a single advert light up every card.
|
||||
static func hostFingerprint(_ seed: Int) -> Data {
|
||||
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
@@ -106,7 +208,7 @@ enum ShotMock {
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
@@ -134,7 +236,7 @@ private struct ShotHome: View {
|
||||
private struct ShotGamepadHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -166,7 +268,7 @@ private struct ShotConnect: View {
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var discovery = ShotMock.discovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
@@ -243,9 +345,9 @@ private struct ShotSettings: View {
|
||||
#elseif os(iOS)
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
|
||||
// codec are what someone reads a streaming app's settings shot to find out.
|
||||
SettingsView(initialCategory: .display)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -255,16 +357,44 @@ private struct ShotSettings: View {
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
|
||||
/// the capture simulator happens to be called.
|
||||
private var sheet: some View {
|
||||
PairSheet(
|
||||
host: ShotMock.host, shotPIN: "418 306",
|
||||
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own
|
||||
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
|
||||
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
|
||||
// scene used to do), the detents were inert, the grouped Form stretched to the full height
|
||||
// of the screen, and the capture was a thin strip of content over a huge black void.
|
||||
ShotHome()
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
|
||||
// the keyboard, and the resting height leaves a wide empty band between the form
|
||||
// and the button row; a capture wants the snug version.
|
||||
sheet.presentationDetents([.fraction(0.52)])
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
|
||||
NavigationStack { sheet }
|
||||
#else
|
||||
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
|
||||
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
|
||||
// -l<windowID>` grabs one window, and an AppKit sheet is a child window — a real `.sheet`
|
||||
// would fall outside the capture.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
sheet
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
|
||||
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
|
||||
// (USB passthrough, or a pad plugged into the host) — capture still runs, and still
|
||||
// watches for the escape chord, but puts nothing on the wire.
|
||||
// System-button routing: whether raw guide/share presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS —
|
||||
// iOS reserves the physical Home press, tvOS never delivers it).
|
||||
let capture = GamepadCapture(
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
|
||||
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
|
||||
systemForward: settings.systemButtonsForward,
|
||||
guideGesture: settings.guideGestureEnabled)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
|
||||
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
|
||||
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
|
||||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@@ -399,6 +401,18 @@ struct GamepadSettingsView: View {
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
|
||||
@@ -34,6 +34,22 @@ enum SettingsOptions {
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
/// System-button routing (the cross-client `system_buttons` key): where the guide
|
||||
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
|
||||
static let systemButtons: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("Send to host", "forward"),
|
||||
("This device", "local"),
|
||||
]
|
||||
|
||||
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
|
||||
/// everywhere but macOS.
|
||||
static let guideGestures: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("On", "on"),
|
||||
("Off", "off"),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
|
||||
@@ -126,6 +126,14 @@ enum SettingsFields {
|
||||
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
|
||||
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
|
||||
}
|
||||
static var systemButtons: SettingsField<String> {
|
||||
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
|
||||
overlay: \.systemButtons, effective: \.systemButtons)
|
||||
}
|
||||
static var guideGesture: SettingsField<String> {
|
||||
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
|
||||
overlay: \.guideGesture, effective: \.guideGesture)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
|
||||
@@ -681,6 +681,29 @@ extension SettingsView {
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Where the guide (Xbox/PS) and share presses go while streaming. "
|
||||
+ "Automatic sends them to the host whenever this device delivers them "
|
||||
+ "— the hold-Select gesture below reaches the host regardless.",
|
||||
field: "system_buttons") {
|
||||
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("Send to host").tag("forward")
|
||||
Text("This device").tag("local")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
described("Hold Select on its own to press the host's guide button — keep "
|
||||
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
|
||||
+ "goes through, slightly delayed. Automatic arms it wherever the real "
|
||||
+ "button can't reach the host (this device reserves it).",
|
||||
field: "guide_gesture") {
|
||||
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
|
||||
Text("Automatic").tag("auto")
|
||||
Text("On").tag("on")
|
||||
Text("Off").tag("off")
|
||||
}
|
||||
.disabled(!effective.gamepadForwarding)
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
|
||||
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
|
||||
|
||||
|
||||
private func persist() {
|
||||
#if DEBUG
|
||||
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
|
||||
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
|
||||
// persisting would replace the tester's saved hosts with "Battlestation" & co.
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
|
||||
static let shared = ProfileStore()
|
||||
|
||||
@Published private(set) var catalog: ProfileCatalog {
|
||||
didSet { catalog.save() }
|
||||
didSet {
|
||||
#if DEBUG
|
||||
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
|
||||
// Saving would write them into the tester's real catalog — see HostStore.persist().
|
||||
if ScreenshotMode.isActive { return }
|
||||
#endif
|
||||
catalog.save()
|
||||
}
|
||||
}
|
||||
|
||||
var profiles: [StreamProfile] { catalog.profiles }
|
||||
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
|
||||
id.flatMap { catalog.profile(id: $0) }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
|
||||
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
|
||||
func debugSet(_ profiles: [StreamProfile]) {
|
||||
catalog = ProfileCatalog(profiles: profiles)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
||||
/// settings", never an error (§4.4).
|
||||
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
||||
|
||||
@@ -109,7 +109,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
TextField(
|
||||
"Client name", text: $clientName,
|
||||
prompt: Text("How the host lists this Mac"))
|
||||
prompt: Text(Self.clientNamePrompt))
|
||||
#if os(tvOS)
|
||||
.labelsHidden() // prefilled → tvOS floats the label off-center
|
||||
#endif
|
||||
@@ -184,6 +184,16 @@ struct PairSheet: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The field prompt names the device you are actually on — it said "this Mac" on every
|
||||
/// platform, which on an iPhone is simply wrong.
|
||||
private static var clientNamePrompt: String {
|
||||
#if os(macOS)
|
||||
"How the host lists this Mac"
|
||||
#else
|
||||
"How the host lists this device"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
@@ -229,3 +239,24 @@ struct PairSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PairSheet {
|
||||
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
|
||||
/// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's
|
||||
/// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
|
||||
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
|
||||
/// mid-entry, with a live primary button.
|
||||
///
|
||||
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
|
||||
/// the private state.
|
||||
init(
|
||||
host: StoredHost, shotPIN: String, shotClientName: String,
|
||||
onPaired: @escaping (Data) -> Void
|
||||
) {
|
||||
self.init(host: host, onPaired: onPaired)
|
||||
_pin = State(initialValue: shotPIN)
|
||||
_clientName = State(initialValue: shotClientName)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
|
||||
|
||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||
public func start() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// A seeded advert set is in force — `start()` must not replace it with the live browse.
|
||||
private var debugPinned = false
|
||||
|
||||
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
|
||||
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
|
||||
/// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once —
|
||||
/// and every mock host reads Offline because nothing advertises it.
|
||||
public func debugSet(_ adverts: [DiscoveredHost]) {
|
||||
stop()
|
||||
debugPinned = true
|
||||
hosts = adverts
|
||||
}
|
||||
|
||||
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
|
||||
/// making it public would expose a wire-shaped model's construction to every consumer just to
|
||||
/// serve the harness.
|
||||
public static func debugAdvert(
|
||||
id: String, name: String, host: String, port: UInt16 = 9777,
|
||||
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
|
||||
macAddresses: [String] = [], osChain: String = ""
|
||||
) -> DiscoveredHost {
|
||||
DiscoveredHost(
|
||||
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
|
||||
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
|
||||
macAddresses: macAddresses, osChain: osChain)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
|
||||
@@ -21,8 +21,12 @@ import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
///
|
||||
/// A caller that owns a particular pad passes the location id it wants (see
|
||||
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
|
||||
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
|
||||
/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
@@ -43,9 +47,57 @@ final class DualSenseHID {
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
/// The IOKit location id of the device this instance opened — the handle a caller correlates
|
||||
/// with its `GCController`. `nil` until a successful `open`.
|
||||
private(set) var locationID: UInt32?
|
||||
|
||||
/// A device's location id, or `nil` if IOKit does not report one.
|
||||
static func locationID(of dev: IOHIDDevice) -> UInt32? {
|
||||
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
|
||||
}
|
||||
|
||||
/// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers.
|
||||
static func attachedLocationIDs() -> [UInt32] {
|
||||
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
let matches = productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
|
||||
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
return []
|
||||
}
|
||||
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
|
||||
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
|
||||
return devices.compactMap(locationID(of:)).sorted()
|
||||
}
|
||||
|
||||
/// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so
|
||||
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
|
||||
///
|
||||
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
|
||||
/// merely arbitrary — it can differ between two calls in one process. With two DualSenses that
|
||||
/// made each renderer's pad→device binding a coin flip: both could land on the same device
|
||||
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
|
||||
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
|
||||
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
|
||||
/// place never displaces one it can.
|
||||
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
|
||||
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
|
||||
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
|
||||
}
|
||||
|
||||
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
|
||||
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
|
||||
let ordered = Array(devices)
|
||||
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
|
||||
return nil
|
||||
}
|
||||
return ordered[i]
|
||||
}
|
||||
|
||||
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
|
||||
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
|
||||
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
@@ -55,13 +107,21 @@ final class DualSenseHID {
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
let dev = Self.pick(devices, preferring: preferred)
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
locationID = Self.locationID(of: dev)
|
||||
if let preferred, locationID != preferred {
|
||||
// Not fatal — one pad still gets rumble — but with two pads attached it means this
|
||||
// renderer is driving the wrong one, and it is invisible without the log line.
|
||||
log.error(
|
||||
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
|
||||
)
|
||||
}
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
@@ -70,8 +130,16 @@ final class DualSenseHID {
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
///
|
||||
/// Returns whether the write reached the device. The caller needs this: it used to be logged
|
||||
/// and swallowed, so a failed write still counted as a successful render. That matters most
|
||||
/// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on
|
||||
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
|
||||
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
|
||||
/// stop left the motors running with nothing scheduled to try again.
|
||||
@discardableResult
|
||||
func rumble(low: UInt8, high: UInt8) -> Bool {
|
||||
guard let dev = device else { return false }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
@@ -81,7 +149,9 @@ final class DualSenseHID {
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
||||
@@ -67,6 +67,17 @@ public final class GamepadCapture {
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
// Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to
|
||||
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
|
||||
// until it resolves into a tap (delivered on release) or — past `guideHold` — a
|
||||
// synthetic guide, down until release.
|
||||
var selectPending = false
|
||||
var selectAsGuide = false
|
||||
/// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out
|
||||
/// outside `buttons`, so `flush` must know to lift it.
|
||||
var tapReleaseOwed = false
|
||||
var gestureTimer: Timer?
|
||||
var tapTimer: Timer?
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
@@ -91,6 +102,16 @@ public final class GamepadCapture {
|
||||
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
|
||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||
private static let disconnectHold: TimeInterval = 1.5
|
||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||
/// down (until release, so a long hold is the host's long-press — a Gaming-Mode
|
||||
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
|
||||
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
|
||||
/// and tvOS never delivers it at all.
|
||||
private static let guideHold: TimeInterval = 0.35
|
||||
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
|
||||
/// its release this far behind — back-to-back transitions can fold into nothing in
|
||||
/// the host's per-pad input fold.
|
||||
private static let tapPress: TimeInterval = 0.05
|
||||
private var chordTimer: Timer?
|
||||
/// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner
|
||||
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
|
||||
@@ -115,10 +136,23 @@ public final class GamepadCapture {
|
||||
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
|
||||
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
|
||||
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
|
||||
/// default true on Apple — where the OS shows its own overlay for them, that's the OS's
|
||||
/// business; local mode exists for profile parity with the Gaming-Mode clients).
|
||||
public let systemForward: Bool
|
||||
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on
|
||||
/// everywhere but macOS). See `guideHold`.
|
||||
public let guideGesture: Bool
|
||||
|
||||
public init(
|
||||
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
|
||||
systemForward: Bool = true, guideGesture: Bool = false
|
||||
) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
self.forwarding = forwarding
|
||||
self.systemForward = systemForward
|
||||
self.guideGesture = guideGesture
|
||||
}
|
||||
|
||||
public func start() {
|
||||
@@ -206,7 +240,14 @@ public final class GamepadCapture {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
// The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit,
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this
|
||||
// BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its
|
||||
// Game Overlay for this press regardless of the gesture claim below (the app is
|
||||
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
|
||||
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
|
||||
// `GCControllerHomeButtonSettingsManager` and surface a one-time
|
||||
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
|
||||
// the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
|
||||
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
|
||||
// handler's pressed value (not via buttonMask), because the legacy
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
@@ -289,7 +330,14 @@ public final class GamepadCapture {
|
||||
// as "changed" — otherwise the first stick/button move after a guide press would emit a
|
||||
// spurious guide-UP while the button is still physically held (and drop the bit from
|
||||
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
|
||||
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
|
||||
var raw = Self.buttonMask(g)
|
||||
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
|
||||
// masked here, guide is gated at its own handler.
|
||||
if !systemForward { raw &= ~GamepadWire.misc1 }
|
||||
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
|
||||
// until it resolves (tap on release / synthetic guide past the threshold).
|
||||
if guideGesture { raw = gestureFiltered(slot, raw) }
|
||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
@@ -312,10 +360,106 @@ public final class GamepadCapture {
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's
|
||||
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
|
||||
/// button joining makes it real (unsuppressed — the diff sends its down); released
|
||||
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
|
||||
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
|
||||
/// guide, lifted here when Select physically releases.
|
||||
///
|
||||
/// One deliberate divergence from the Rust worker: while transformed into a guide the
|
||||
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
|
||||
/// an in-flight guide-hold — release Select and press the chord plainly instead (the
|
||||
/// chord's four-at-once press never lingers in pending long enough to be affected).
|
||||
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
|
||||
let back = GamepadWire.back
|
||||
let backDown = raw & back != 0
|
||||
let othersDown = raw & ~back != 0
|
||||
if slot.selectAsGuide {
|
||||
if backDown { return raw & ~back }
|
||||
slot.selectAsGuide = false
|
||||
sendGuide(slot, down: false, raw: false)
|
||||
return raw
|
||||
}
|
||||
if slot.selectPending {
|
||||
if !backDown {
|
||||
endPending(slot)
|
||||
deliverTap(slot)
|
||||
return raw
|
||||
}
|
||||
if othersDown {
|
||||
// A combo after all — Select unsuppresses and the diff sends its down.
|
||||
endPending(slot)
|
||||
return raw
|
||||
}
|
||||
return raw & ~back
|
||||
}
|
||||
if backDown, !othersDown, slot.buttons & back == 0 {
|
||||
// Newly pressed, alone: hold it back. An owed tap release goes out first so
|
||||
// the host never sees two downs in a row.
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
slot.selectPending = true
|
||||
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.gestureHoldFired(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = timer
|
||||
return raw & ~back
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// The hold threshold passed with Select still pending → it IS the guide now, down
|
||||
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
|
||||
private func gestureHoldFired(_ slot: Slot) {
|
||||
guard slot.selectPending else { return }
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer = nil
|
||||
slot.selectAsGuide = true
|
||||
sendGuide(slot, down: true, raw: false)
|
||||
}
|
||||
|
||||
private func endPending(_ slot: Slot) {
|
||||
slot.selectPending = false
|
||||
slot.gestureTimer?.invalidate()
|
||||
slot.gestureTimer = nil
|
||||
}
|
||||
|
||||
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
|
||||
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
|
||||
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
|
||||
/// outlive the slot.
|
||||
private func deliverTap(_ slot: Slot) {
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
|
||||
slot.tapReleaseOwed = true
|
||||
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
|
||||
Task { @MainActor in
|
||||
if let self, let slot { self.finishTap(slot) }
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = timer
|
||||
}
|
||||
|
||||
private func finishTap(_ slot: Slot) {
|
||||
guard slot.tapReleaseOwed else { return }
|
||||
slot.tapReleaseOwed = false
|
||||
slot.tapTimer?.invalidate()
|
||||
slot.tapTimer = nil
|
||||
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
|
||||
/// physical Home handler's calls, which the system-buttons policy can keep local; the
|
||||
/// gesture's synthetic transitions pass `raw: false` and always go out.
|
||||
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
|
||||
if raw, !systemForward { return }
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
@@ -449,6 +593,12 @@ public final class GamepadCapture {
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
|
||||
// goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide`
|
||||
// — is lifted by the loop below like any held button.
|
||||
endPending(slot)
|
||||
slot.selectAsGuide = false
|
||||
if slot.tapReleaseOwed { finishTap(slot) }
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
|
||||
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
|
||||
// per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the
|
||||
// renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose
|
||||
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
|
||||
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
|
||||
// change, and the main thread is what drives the presenter's CADisplayLink, so
|
||||
// blocking here hitches the picture mid-stream. The renderer is already detached from
|
||||
// routing above, so nothing observes it after this point.
|
||||
if let renderer { Task.detached { renderer.stop() } }
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
|
||||
private func reset(_ controller: GCController?) {
|
||||
guard let c = controller else { return }
|
||||
c.playerIndex = .indexUnset
|
||||
// Put the lightbar out too. This class is what turned it on (see the `Led` and
|
||||
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
|
||||
// set stays lit in firmware after the stream ends — back at the launcher, or for a pad
|
||||
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
|
||||
// indicator IS the lightbar; a DualSense is not.
|
||||
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
|
||||
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
|
||||
@@ -459,6 +459,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
if split {
|
||||
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
|
||||
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
|
||||
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
|
||||
// which handle died — lose `high` and `render` falls to the combined branch (selected
|
||||
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
|
||||
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
|
||||
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
|
||||
// which at least renders both motors somewhere.
|
||||
if low == nil || high == nil {
|
||||
log.warning("rumble: only one split-handle engine came up — falling back to combined")
|
||||
teardown() // disarms handlers, stops the survivor's players + engine, nils both
|
||||
}
|
||||
} else {
|
||||
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||
}
|
||||
@@ -587,7 +599,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
// Ask for the device this renderer's controller actually is, so two attached DualSenses
|
||||
// do not both get driven through whichever one an unordered Set happened to yield first.
|
||||
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
@@ -595,6 +609,24 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Correlate a `GCController` with an IOKit location id.
|
||||
///
|
||||
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
|
||||
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
|
||||
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
|
||||
/// missing. With one pad attached this is the same device it always was.
|
||||
static func hidLocationID(for c: GCController) -> UInt32? {
|
||||
let ids = DualSenseHID.attachedLocationIDs()
|
||||
guard ids.count > 1 else { return ids.first }
|
||||
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
|
||||
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
|
||||
return ids.first
|
||||
}
|
||||
return ids[rank]
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||
@@ -605,8 +637,20 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let keepalive = levels != (0, 0)
|
||||
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
|
||||
if levels != lastHidWrite.levels || keepalive {
|
||||
hid.rumble(low: levels.0, high: levels.1)
|
||||
lastHidWrite = (levels, .now())
|
||||
if hid.rumble(low: levels.0, high: levels.1) {
|
||||
lastHidWrite = (levels, .now())
|
||||
} else {
|
||||
// The write did not reach the device. Do NOT stamp the clock — that would claim a
|
||||
// render that never happened, and for a stop there is nothing behind it: the
|
||||
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
|
||||
// target is (0, 0), so the motors would keep running with nothing scheduled.
|
||||
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
|
||||
// rebuilds it. Health is reported so the state is visible rather than silent.
|
||||
log.error("rumble: HID write failed — dropping the handle, falling back")
|
||||
closeHID()
|
||||
reportHealth("Lost the direct connection to this DualSense; using the system path.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
|
||||
@@ -38,6 +38,17 @@ public enum DefaultsKey {
|
||||
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
|
||||
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
|
||||
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
|
||||
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
|
||||
/// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto
|
||||
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
|
||||
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
|
||||
/// send would gain nothing.
|
||||
public static let systemButtons = "punktfunk.systemButtons"
|
||||
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client
|
||||
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
|
||||
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
|
||||
/// holding Select is the controller route to the host's guide there.
|
||||
public static let guideGesture = "punktfunk.guideGesture"
|
||||
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
||||
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
public var invertScroll = false
|
||||
public var gamepadType = 0
|
||||
public var gamepadForwarding = true
|
||||
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
|
||||
public var systemButtons = "auto"
|
||||
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
|
||||
public var guideGesture = "auto"
|
||||
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
|
||||
public var statsVerbosity = "normal"
|
||||
public var fullscreenWhileStreaming = true
|
||||
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
|
||||
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
|
||||
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
|
||||
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
|
||||
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
|
||||
statsVerbosity = Self.storedStatsVerbosity(defaults)
|
||||
fullscreenWhileStreaming = bool(
|
||||
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
|
||||
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
return "normal"
|
||||
}
|
||||
|
||||
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
|
||||
/// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows
|
||||
/// its own overlay for the press that is the OS's business, and suppressing our send
|
||||
/// would only break users who handed the button to the app (iOS 27's Home-button
|
||||
/// setting; macOS with the gestures claimed).
|
||||
public var systemButtonsForward: Bool {
|
||||
switch systemButtons {
|
||||
case "local": return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
|
||||
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
|
||||
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
|
||||
/// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the
|
||||
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
|
||||
public var guideGestureEnabled: Bool {
|
||||
switch guideGesture {
|
||||
case "on": return true
|
||||
case "off": return false
|
||||
default:
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no
|
||||
/// clock — so it is testable field by field. A `.some` that happens to equal the base is a
|
||||
/// legitimate PIN: it keeps its value when the global later moves.
|
||||
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
if let v = overlay.invertScroll { s.invertScroll = v }
|
||||
if let v = overlay.gamepadType { s.gamepadType = v }
|
||||
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
|
||||
if let v = overlay.systemButtons { s.systemButtons = v }
|
||||
if let v = overlay.guideGesture { s.guideGesture = v }
|
||||
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
|
||||
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
|
||||
if let v = overlay.enable444 { s.enable444 = v }
|
||||
|
||||
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
public var invertScroll: Bool?
|
||||
public var gamepadType: Int?
|
||||
public var gamepadForwarding: Bool?
|
||||
public var systemButtons: String?
|
||||
public var guideGesture: String?
|
||||
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in
|
||||
/// PunktfunkKit, which this module must not depend on.
|
||||
public var statsVerbosity: String?
|
||||
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
case invertScroll = "invert_scroll"
|
||||
case gamepadType = "gamepad"
|
||||
case gamepadForwarding = "gamepad_forwarding"
|
||||
case systemButtons = "system_buttons"
|
||||
case guideGesture = "guide_gesture"
|
||||
case statsVerbosity = "stats_verbosity"
|
||||
case fullscreenWhileStreaming = "fullscreen_on_stream"
|
||||
case enable444 = "enable_444"
|
||||
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
invertScroll = bool(.invertScroll)
|
||||
gamepadType = int(.gamepadType)
|
||||
gamepadForwarding = bool(.gamepadForwarding)
|
||||
systemButtons = str(.systemButtons)
|
||||
guideGesture = str(.guideGesture)
|
||||
statsVerbosity = str(.statsVerbosity)
|
||||
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
|
||||
enable444 = bool(.enable444)
|
||||
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
|
||||
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
|
||||
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
|
||||
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
|
||||
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
|
||||
try c.encodeIfPresent(
|
||||
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
|
||||
@@ -277,6 +285,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": overlay.invertScroll = nil
|
||||
case "gamepad": overlay.gamepadType = nil
|
||||
case "gamepad_forwarding": overlay.gamepadForwarding = nil
|
||||
case "system_buttons": overlay.systemButtons = nil
|
||||
case "guide_gesture": overlay.guideGesture = nil
|
||||
case "stats_verbosity": overlay.statsVerbosity = nil
|
||||
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
|
||||
case "enable_444": overlay.enable444 = nil
|
||||
@@ -313,6 +323,8 @@ public enum OverlayField {
|
||||
case "invert_scroll": return o.invertScroll != nil
|
||||
case "gamepad": return o.gamepadType != nil
|
||||
case "gamepad_forwarding": return o.gamepadForwarding != nil
|
||||
case "system_buttons": return o.systemButtons != nil
|
||||
case "guide_gesture": return o.guideGesture != nil
|
||||
case "stats_verbosity": return o.statsVerbosity != nil
|
||||
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
|
||||
case "enable_444": return o.enable444 != nil
|
||||
|
||||
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
|
||||
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||
}
|
||||
|
||||
// MARK: - Device selection (B14)
|
||||
|
||||
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
|
||||
/// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could
|
||||
/// point both renderers at the same pad.
|
||||
func testPreferredIndexHonoursAnExplicitLocation() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
|
||||
}
|
||||
|
||||
/// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but
|
||||
/// stable across calls, which `Set.first` was not.
|
||||
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
|
||||
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
|
||||
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
|
||||
// open — it degrades to the same stable fallback.
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
|
||||
}
|
||||
|
||||
/// A device IOKit reports no location for must never displace one it can place.
|
||||
func testPreferredIndexSortsUnplaceableDevicesLast() {
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
|
||||
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
|
||||
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# App Store copy
|
||||
|
||||
Source of truth for what goes into App Store Connect. Every character-limited field in here has
|
||||
been counted with `check-limits.py`; run it after any edit.
|
||||
|
||||
```sh
|
||||
python3 clients/apple/store/check-limits.py
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
|
||||
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
|
||||
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
|
||||
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
|
||||
|
||||
German is primary throughout and uses the same informal "du" voice as the website
|
||||
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
|
||||
few lines diverge where the German idiom does not carry.
|
||||
|
||||
## Three things that contradicted the original brief
|
||||
|
||||
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
|
||||
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
|
||||
display, or encode backend. The macOS copy is client-only and says so explicitly.
|
||||
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
|
||||
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
|
||||
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
|
||||
the app. See `privacy-app-addendum.md` for the text to append.
|
||||
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
|
||||
template is 3919 and fits.
|
||||
|
||||
## Claims used, and where they come from
|
||||
|
||||
Everything asserted in the copy was checked against the source rather than the marketing site:
|
||||
|
||||
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
|
||||
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
|
||||
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
|
||||
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
|
||||
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
|
||||
symbols in `Sources/`, `URLSession` used only against the paired host
|
||||
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
|
||||
- Feature ship dates — `git tag --contains` on the relevant commits
|
||||
|
||||
## Not done here
|
||||
|
||||
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
|
||||
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
|
||||
alone because it is a code change, not copy.
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check every App Store copy block in this directory against its field limit.
|
||||
|
||||
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
|
||||
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
|
||||
so `len()` on a `str` is the right measure — do not switch this to a byte count.
|
||||
|
||||
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
|
||||
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
|
||||
|
||||
|
||||
def blocks(text: str):
|
||||
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
|
||||
heading = None
|
||||
buf: list[str] | None = None
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("#") and buf is None:
|
||||
heading = line.lstrip("#").strip()
|
||||
if line.strip() == "```":
|
||||
if buf is None:
|
||||
buf = []
|
||||
else:
|
||||
yield heading or "", "\n".join(buf)
|
||||
buf = None
|
||||
continue
|
||||
if buf is not None:
|
||||
buf.append(line)
|
||||
|
||||
|
||||
def kind_of(heading: str, body: str) -> str:
|
||||
low = heading.lower()
|
||||
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
|
||||
return "KW"
|
||||
if "template" in low:
|
||||
return "NOTES"
|
||||
return "DESC" if len(body) > 400 else "PROMO"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
here = pathlib.Path(__file__).parent
|
||||
failures = 0
|
||||
stale = 0
|
||||
for path in sorted(here.glob("*.md")):
|
||||
found = list(blocks(path.read_text(encoding="utf-8")))
|
||||
if not found:
|
||||
continue
|
||||
print(f"\n=== {path.name} ===")
|
||||
for heading, body in found:
|
||||
kind = kind_of(heading, body)
|
||||
limit = LIMITS[kind]
|
||||
n = len(body)
|
||||
over = n > limit
|
||||
failures += over
|
||||
# Headings carry the count in parentheses; flag any that drifted from the real length.
|
||||
claimed = re.search(r"\((\d+)\)\s*$", heading)
|
||||
drift = ""
|
||||
if claimed and int(claimed.group(1)) != n:
|
||||
drift = f" [heading claims {claimed.group(1)}]"
|
||||
stale += 1
|
||||
status = "OVER" if over else "ok"
|
||||
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{failures} block(s) OVER the limit")
|
||||
elif stale:
|
||||
print(f"\nAll within limits, but {stale} heading count(s) are stale")
|
||||
else:
|
||||
print("\nAll blocks within limits, all heading counts accurate")
|
||||
return 1 if failures or stale else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# iOS / iPadOS — App Store metadata
|
||||
|
||||
Existing, unchanged:
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
|
||||
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
|
||||
build or a review, so it is the right place for "what landed most recently".
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook, no "new" claim (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung – ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (161)
|
||||
|
||||
```
|
||||
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
|
||||
```
|
||||
|
||||
### Alternate C — leads on latency (153)
|
||||
|
||||
```
|
||||
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (152)
|
||||
|
||||
```
|
||||
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
|
||||
```
|
||||
|
||||
### Alternate A — evergreen hook (159)
|
||||
|
||||
```
|
||||
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes on the claims
|
||||
|
||||
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
|
||||
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
|
||||
drop the word once 0.25 ships something newer.
|
||||
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
|
||||
*mention* but should not be called "neu".
|
||||
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
|
||||
(`b297542c`), which is too niche to headline.
|
||||
@@ -0,0 +1,159 @@
|
||||
# macOS — App Store metadata
|
||||
|
||||
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
|
||||
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
|
||||
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
|
||||
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
|
||||
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
|
||||
> None of the copy below claims a Mac can host, and it should not until that ships.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (164)
|
||||
|
||||
```
|
||||
Neu: Profile pro Host – ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
|
||||
```
|
||||
|
||||
### Alternate (156)
|
||||
|
||||
```
|
||||
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streamt deinen Gaming-PC auf den Mac – in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
|
||||
|
||||
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
|
||||
|
||||
Für jede Verbindung legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox – H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
|
||||
|
||||
FÜR DEN MAC GEMACHT
|
||||
|
||||
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
|
||||
• Maus und Tastatur gehen vollständig durch – Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
|
||||
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
|
||||
• Mikrofon-Uplink mit Echounterdrückung – dein Mac wird zum Headset am PC
|
||||
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
|
||||
|
||||
DEINE BIBLIOTHEK, DEIN NETZWERK
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund – kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
|
||||
|
||||
MESSEN STATT GLAUBEN
|
||||
|
||||
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
|
||||
|
||||
YOUR MAC GETS A DISPLAY OF ITS OWN
|
||||
|
||||
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
|
||||
|
||||
BUILT FOR THE MAC
|
||||
|
||||
• In a window or full screen, on any attached display
|
||||
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
|
||||
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
|
||||
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
|
||||
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
|
||||
|
||||
YOUR LIBRARY, YOUR NETWORK
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
|
||||
|
||||
MEASURED, NOT PROMISED
|
||||
|
||||
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
|
||||
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
|
||||
absent — repeating them would waste characters.
|
||||
|
||||
### DE (97)
|
||||
|
||||
```
|
||||
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
|
||||
```
|
||||
|
||||
### EN (95)
|
||||
|
||||
```
|
||||
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
|
||||
```
|
||||
|
||||
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
|
||||
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
|
||||
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
|
||||
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
|
||||
Moonlight and mention Steam only as a factual statement about your own library.
|
||||
|
||||
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
|
||||
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Privacy — what to link from App Store Connect
|
||||
|
||||
## The situation
|
||||
|
||||
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
|
||||
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
|
||||
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
|
||||
self-hosted fonts. It does not mention the apps at all.
|
||||
|
||||
That is a problem for App Store Connect in two directions:
|
||||
|
||||
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
|
||||
the link finds a page about a website.
|
||||
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
|
||||
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
|
||||
Analytics" and has every reason to question the App Privacy answers.
|
||||
|
||||
**Recommendation:** keep the existing page and append an app-specific section to it (the text
|
||||
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
|
||||
works, but one URL is less to keep in sync.
|
||||
|
||||
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
|
||||
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
|
||||
code change.
|
||||
|
||||
## Confirming the "collects no data" framing
|
||||
|
||||
Checked against the source rather than taken on trust, and it holds:
|
||||
|
||||
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
|
||||
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
|
||||
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
|
||||
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
|
||||
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
|
||||
**from the paired host**, over a TLS session that pins the host's own certificate. The only
|
||||
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
|
||||
the source on `git.unom.io`, and the Discord invite.
|
||||
- **No account system.** Identity is a client keypair in the device keychain
|
||||
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
|
||||
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
|
||||
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
|
||||
entitlement.
|
||||
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
|
||||
|
||||
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
|
||||
stating in the policy text anyway, because they are true and pre-empt questions:
|
||||
|
||||
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
|
||||
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
|
||||
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
|
||||
is better than staying silent about a microphone permission.
|
||||
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
|
||||
Apple's processing, not yours, but naming it avoids looking like an omission.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — Deutsch
|
||||
|
||||
> ## Die Punktfunk-Apps
|
||||
>
|
||||
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
|
||||
> Android – im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
|
||||
>
|
||||
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
|
||||
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
|
||||
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
|
||||
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
|
||||
> übermittelt.
|
||||
>
|
||||
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
|
||||
> selbst betreiben – in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
|
||||
> Controller-Eingaben sowie – sofern Sie ihn einschalten – Ihr Mikrofon werden ausschließlich
|
||||
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
|
||||
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
|
||||
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
|
||||
>
|
||||
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
|
||||
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
|
||||
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
|
||||
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
|
||||
> wenn Sie die App entfernen.
|
||||
>
|
||||
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
|
||||
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und – nur
|
||||
> wenn Sie die Mikrofonübertragung nutzen – das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
|
||||
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
|
||||
> aufgezeichnet und nicht an uns gesendet.
|
||||
>
|
||||
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
|
||||
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
|
||||
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
|
||||
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
|
||||
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
|
||||
>
|
||||
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
|
||||
> Rechner betreiben. Welche Daten dabei anfallen – etwa lokale Protokolldateien –, bleibt
|
||||
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
|
||||
> git.unom.io/unom/punktfunk einsehbar.
|
||||
|
||||
---
|
||||
|
||||
## Text to append — English
|
||||
|
||||
> ## The Punktfunk apps
|
||||
>
|
||||
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
|
||||
> Android — as distinct from the sections above, which concern this website.
|
||||
>
|
||||
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
|
||||
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
|
||||
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
|
||||
> data is transmitted to us or to any third party.
|
||||
>
|
||||
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
|
||||
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
|
||||
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
|
||||
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
|
||||
> streaming, and we have no access to the contents of a session at any point.
|
||||
>
|
||||
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
|
||||
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
|
||||
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
|
||||
> your device and is removed when you delete the app.
|
||||
>
|
||||
> **Permissions.** The app requests only the permissions it needs to work: access to the local
|
||||
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
|
||||
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
|
||||
> virtual microphone; it is not recorded and is not sent to us.
|
||||
>
|
||||
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
|
||||
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
|
||||
> and crash statistics). We have no influence over this, and the respective provider's privacy
|
||||
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
|
||||
> not allow any individual to be identified.
|
||||
>
|
||||
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
|
||||
> data it produces — local log files, for instance — remains entirely under your control, and none
|
||||
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
|
||||
|
||||
---
|
||||
|
||||
## Also update
|
||||
|
||||
- Bump **Stand: / Effective date:** on the page when you add this.
|
||||
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
|
||||
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# App Review notes
|
||||
|
||||
## The core problem, stated plainly
|
||||
|
||||
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
|
||||
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
|
||||
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
|
||||
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
|
||||
on their network sees an empty "On this network" list.
|
||||
|
||||
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
|
||||
attach **one** of:
|
||||
|
||||
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
|
||||
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
|
||||
can add a host by IP or hostname, so mDNS discovery is not required for this path.
|
||||
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
|
||||
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
|
||||
|
||||
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
|
||||
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
|
||||
|
||||
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
|
||||
> section with the video URL and say explicitly that no host can be provided.
|
||||
|
||||
---
|
||||
|
||||
## Notes template — paste into App Store Connect
|
||||
|
||||
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
|
||||
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
|
||||
longer than the values that replace it). If you add to it, re-check the count — an over-long note
|
||||
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
|
||||
live.
|
||||
|
||||
```
|
||||
WHAT THIS APP IS
|
||||
|
||||
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
|
||||
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
|
||||
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
|
||||
and this app has no purchases.
|
||||
|
||||
This app is the client half only: it renders video and audio from the user's own machine and
|
||||
sends input back. There is no content library and no server of ours in a session.
|
||||
|
||||
IMPORTANT: THIS APP NEEDS A HOST
|
||||
|
||||
With no reachable host, the app can only show its host list, the pairing screen and settings --
|
||||
inherent to what it is, not an incomplete build. We have provided a live host for review.
|
||||
|
||||
CONNECTING TO OUR DEMO HOST
|
||||
|
||||
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
|
||||
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
|
||||
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
|
||||
Name it anything, then confirm.
|
||||
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
|
||||
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
|
||||
4. The host's game library appears as a grid. Select any title to stream; video and audio start
|
||||
within a few seconds.
|
||||
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
|
||||
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
|
||||
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
|
||||
"Speed test" suggests a bitrate for the link.
|
||||
|
||||
The host stays reachable throughout review. If you cannot reach it, please contact
|
||||
[[FILL IN: contact email]] and we will restore it promptly.
|
||||
|
||||
WHY THE APP ASKS FOR WHAT IT ASKS FOR
|
||||
|
||||
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
|
||||
entire purpose.
|
||||
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
|
||||
there as a virtual microphone for voice chat. Never recorded, never sent to us.
|
||||
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
|
||||
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
|
||||
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
|
||||
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
|
||||
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
|
||||
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
|
||||
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
|
||||
without this, no video, audio or rumble arrives.
|
||||
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
|
||||
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
|
||||
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
|
||||
silence to stay alive, nor use the mode outside an audible session.
|
||||
|
||||
REGARDING BUILD 0.4.2 (3384)
|
||||
|
||||
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
|
||||
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
|
||||
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
|
||||
no temporary exception.
|
||||
|
||||
ACCOUNTS, PURCHASES, DATA
|
||||
|
||||
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
|
||||
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
|
||||
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
|
||||
|
||||
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before you submit — checklist
|
||||
|
||||
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
|
||||
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
|
||||
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
|
||||
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
|
||||
review window, and that pairing is left open (it is on-demand in the web console).
|
||||
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
|
||||
successful pairing looks like a broken app.
|
||||
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
|
||||
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
|
||||
tvOS rejection.
|
||||
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
|
||||
|
||||
## Separately worth checking: the privacy manifest
|
||||
|
||||
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
|
||||
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
|
||||
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
|
||||
missing a declaration typically get an automated **ITMS-91053** notice on upload.
|
||||
|
||||
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
|
||||
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
|
||||
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
|
||||
submission. Confirm the current reason codes against Apple's documentation rather than taking the
|
||||
code above on trust; the list has changed since it was introduced.
|
||||
@@ -0,0 +1,145 @@
|
||||
# tvOS — App Store metadata
|
||||
|
||||
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
|
||||
and which the copy therefore avoids claiming:
|
||||
|
||||
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
|
||||
headset" line does not transfer.
|
||||
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
|
||||
macOS only — tvOS uses the native focus engine instead.
|
||||
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
|
||||
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
|
||||
|
||||
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
|
||||
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
|
||||
best Apple TV feature, because it is what removes the trip to the other room.
|
||||
|
||||
- **Name:** Punktfunk
|
||||
- **Subtitle (DE):** Schnell, lokal & offen.
|
||||
- **Subtitle (EN):** Fast, local & open.
|
||||
|
||||
---
|
||||
|
||||
## Promotional Text (DE) — max 170 characters
|
||||
|
||||
### Primary (161)
|
||||
|
||||
```
|
||||
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (157)
|
||||
|
||||
```
|
||||
Dein Gaming-PC am großen Bildschirm – in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
|
||||
```
|
||||
|
||||
### Alternate B — leads on the DualSense (160)
|
||||
|
||||
```
|
||||
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
|
||||
```
|
||||
|
||||
## Promotional Text (EN) — max 170 characters
|
||||
|
||||
### Primary (160)
|
||||
|
||||
```
|
||||
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
|
||||
```
|
||||
|
||||
### Alternate A — leads on the picture (148)
|
||||
|
||||
```
|
||||
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (DE) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht – in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
|
||||
|
||||
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auch headless auf einem Rechner, an dem gar kein Monitor hängt.
|
||||
|
||||
VOM SOFA AUS, VON ANFANG BIS ENDE
|
||||
|
||||
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität – kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
|
||||
|
||||
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
|
||||
|
||||
Für den Apple TV legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
|
||||
|
||||
CONTROLLER, VOLLSTÄNDIG
|
||||
|
||||
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller – die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
|
||||
|
||||
DEINE BIBLIOTHEK AUF DEM FERNSEHER
|
||||
|
||||
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display – der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
|
||||
|
||||
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
|
||||
|
||||
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
|
||||
|
||||
WAS DU BRAUCHST
|
||||
|
||||
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io.
|
||||
|
||||
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Description (EN) — max 4000 characters
|
||||
|
||||
```
|
||||
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
|
||||
|
||||
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
|
||||
|
||||
FROM THE COUCH, START TO FINISH
|
||||
|
||||
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
|
||||
|
||||
THE PICTURE YOUR TV CAN ACTUALLY SHOW
|
||||
|
||||
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
|
||||
|
||||
CONTROLLERS, IN FULL
|
||||
|
||||
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
|
||||
|
||||
YOUR LIBRARY ON THE BIG SCREEN
|
||||
|
||||
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
|
||||
|
||||
FAST, BECAUSE WE OWN THE WHOLE PATH
|
||||
|
||||
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
|
||||
|
||||
WHAT YOU NEED
|
||||
|
||||
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
|
||||
|
||||
No account. No cloud. No telemetry. This app collects no data about you.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keywords — max 100 characters
|
||||
|
||||
### DE (93)
|
||||
|
||||
```
|
||||
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
|
||||
```
|
||||
|
||||
### EN (91)
|
||||
|
||||
```
|
||||
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
|
||||
```
|
||||
|
||||
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
|
||||
@@ -11,9 +11,15 @@
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
|
||||
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
|
||||
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
|
||||
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
|
||||
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
|
||||
# The App Store set, in listing order — the first three are what most people ever see, so they are
|
||||
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
|
||||
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
|
||||
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
@@ -89,13 +99,20 @@ shoot_macos() {
|
||||
|
||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
||||
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
|
||||
# $6 name for a device we have to create — MUST satisfy $1 (see below)
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
|
||||
|
||||
# Reuse an existing device of this type; else create a throwaway one against the newest
|
||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
# Reuse an existing device of this type; else create one against the newest available runtime
|
||||
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
|
||||
# Max is absent on ours), so create-on-demand is what makes it reproducible.
|
||||
#
|
||||
# The created device is named after the DEVICE, not after this script, for two reasons. It used
|
||||
# to be "pf-shot-<prefix>", which `$match` never matches — so every run created another
|
||||
# simulator and none was ever reused (they piled up on the runner). And the name is user-visible:
|
||||
# `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so
|
||||
# "pf-shot-iphone-6.9" was rendered into an App Store screenshot.
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
@@ -105,8 +122,8 @@ shoot_sim() {
|
||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
||||
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
|
||||
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||
@@ -114,6 +131,11 @@ shoot_sim() {
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
|
||||
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
|
||||
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
|
||||
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
|
||||
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
|
||||
@@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
|
||||
@@ -647,6 +647,19 @@ async def _native_update_state() -> dict:
|
||||
return {"error": "client-outdated"} if outdated else {}
|
||||
|
||||
|
||||
def _ctl_sockets() -> list[Path]:
|
||||
"""Candidate paths of the streaming client's control socket (guide/QAM injection):
|
||||
the flatpak app runtime dir first (the one runtime path the sandbox and this backend
|
||||
see identically), then the plain runtime dir (native installs). Mirrors the session
|
||||
binary's ``ctl_socket::path``."""
|
||||
uid = os.environ.get("PF_UID") or "1000"
|
||||
run = Path(f"/run/user/{uid}")
|
||||
return [
|
||||
run / "app" / APP_ID / "punktfunk-session-ctl.sock",
|
||||
run / "punktfunk-session-ctl.sock",
|
||||
]
|
||||
|
||||
|
||||
class Plugin:
|
||||
# ---- Thin shells over the headless CLI -------------------------------------------------
|
||||
#
|
||||
@@ -842,6 +855,46 @@ class Plugin:
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
async def stream_running(self) -> dict:
|
||||
"""Whether the streaming client's control socket exists — i.e. the client is up.
|
||||
|
||||
The socket appears at the client's first stream and lives for the process, so
|
||||
between console-mode streams it lingers; that only leaves the panel's host
|
||||
buttons harmlessly visible.
|
||||
"""
|
||||
return {"running": any(p.is_socket() for p in _ctl_sockets())}
|
||||
|
||||
async def host_action(self, action: str) -> dict:
|
||||
"""Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS
|
||||
menu button) or ``qam`` (the quick-access ``…``).
|
||||
|
||||
Talks to the session binary's control socket (one text verb per connection,
|
||||
``ok``/``err`` back) — the flatpak app runtime dir first (the sandboxed client;
|
||||
that dir is the one runtime path host and sandbox see identically), then the
|
||||
plain runtime dir (native installs). No socket = no running stream.
|
||||
"""
|
||||
if action not in ("guide", "qam"):
|
||||
return {"ok": False, "error": f"unknown action {action!r}"}
|
||||
for sock in _ctl_sockets():
|
||||
if not sock.is_socket():
|
||||
continue
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_unix_connection(str(sock)), timeout=2.0
|
||||
)
|
||||
except Exception: # noqa: BLE001 — a stale socket file; try the next path
|
||||
continue
|
||||
try:
|
||||
writer.write(f"{action}\n".encode())
|
||||
await writer.drain()
|
||||
reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
return {"ok": reply.strip() == b"ok"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
writer.close()
|
||||
return {"ok": False, "error": "no-stream"}
|
||||
|
||||
async def _update_native_client(self) -> dict:
|
||||
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
|
||||
``--apply-update``, which starts the packaged root helper.
|
||||
|
||||
@@ -170,6 +170,15 @@ export const applyControllerConfig = callable<
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Whether the streaming client's control socket exists (a stream/console client is up) —
|
||||
// gates the QAM panel's host-button section.
|
||||
export const streamRunning = callable<[], { running: boolean }>("stream_running");
|
||||
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
|
||||
// presses stay on the Deck by default (the client's Controllers settings), so this — and
|
||||
// holding Select — is how the host's own menus are reached.
|
||||
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
|
||||
"host_action",
|
||||
);
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
ButtonItem,
|
||||
Field,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
Spinner,
|
||||
@@ -15,9 +16,10 @@ import {
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, toaster } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaGamepad,
|
||||
FaLock,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { killStream } from "./backend";
|
||||
import { hostAction, killStream, streamRunning } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
@@ -66,6 +68,22 @@ async function forceStop(): Promise<void> {
|
||||
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
|
||||
}
|
||||
|
||||
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
|
||||
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
|
||||
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
|
||||
// is the panel route to the host's menus; holding Select is the controller route.
|
||||
async function pressHost(action: "guide" | "qam"): Promise<void> {
|
||||
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
|
||||
if (r.ok) {
|
||||
Navigation.CloseSideMenus();
|
||||
} else {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
|
||||
function hostDescription(v: HostView): string {
|
||||
const trust = {
|
||||
@@ -127,6 +145,18 @@ const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh })
|
||||
const QamPanel: FC = () => {
|
||||
const { views, scanning, problem, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
// The host-buttons section shows only while the streaming client is up (checked per
|
||||
// panel open — the QAM panel mounts fresh each time).
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void streamRunning()
|
||||
.then((r) => live && setStreaming(r.running))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -230,6 +260,31 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{streaming && (
|
||||
<PanelSection title="Host menus">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Press the Steam/guide button on the host"
|
||||
onClick={() => void pressHost("guide")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Steam menu on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Open the host's Quick Access Menu"
|
||||
onClick={() => void pressHost("qam")}
|
||||
>
|
||||
<FaGamepad style={{ marginRight: "0.5em" }} />
|
||||
Quick access on host
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
|
||||
@@ -157,6 +157,20 @@ mod index {
|
||||
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn system_buttons(s: &Settings) -> u32 {
|
||||
SYSTEM_BUTTONS
|
||||
.iter()
|
||||
.position(|&v| v == s.system_buttons)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn guide_gesture(s: &Settings) -> u32 {
|
||||
GUIDE_GESTURES
|
||||
.iter()
|
||||
.position(|&v| v == s.guide_gesture)
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
pub fn present_priority(s: &Settings) -> u32 {
|
||||
// Unknown values (a newer client's intent) read as the default, exactly as
|
||||
// `PresentPriority::resolve` treats them.
|
||||
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
if touched.has("gamepad_forwarding") {
|
||||
o.gamepad_forwarding = Some(values.gamepad_forwarding);
|
||||
}
|
||||
if touched.has("system_buttons") {
|
||||
o.system_buttons = Some(values.system_buttons.clone());
|
||||
}
|
||||
if touched.has("guide_gesture") {
|
||||
o.guide_gesture = Some(values.guide_gesture.clone());
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
|
||||
"dualshock4",
|
||||
"steamdeck",
|
||||
];
|
||||
/// System-button routing values (persisted under the cross-client `system_buttons` key):
|
||||
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
|
||||
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
|
||||
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
|
||||
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
|
||||
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
|
||||
/// only where the raw guide press can't reach the host (Gaming Mode here).
|
||||
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
|
||||
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
|
||||
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
|
||||
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
// Both pad rows only mean something while something is being forwarded (the same
|
||||
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
|
||||
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
|
||||
// needs either off Automatic — they exist here because profiles are authored on the
|
||||
// desktop and applied everywhere, Gaming Mode included.
|
||||
let sysbtn_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Steam / guide button",
|
||||
"Automatic sends it to the host, except where this device reacts to it too",
|
||||
SYSTEM_BUTTON_LABELS,
|
||||
);
|
||||
let gesture_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Hold Select for guide",
|
||||
"Hold Select alone for the host's guide button — a tap still goes through",
|
||||
GUIDE_GESTURE_LABELS,
|
||||
);
|
||||
// The pad rows only mean something while something is being forwarded (the same
|
||||
// relationship mic → echo cancellation draws just above, initial state included: the
|
||||
// seed's `set_active` fires this only when it CHANGES the switch).
|
||||
{
|
||||
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
|
||||
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
|
||||
f.set_sensitive(seed.gamepad_forwarding);
|
||||
t.set_sensitive(seed.gamepad_forwarding);
|
||||
sb.set_sensitive(seed.gamepad_forwarding);
|
||||
gg.set_sensitive(seed.gamepad_forwarding);
|
||||
pad_forward_row.connect_active_notify(move |r| {
|
||||
f.set_sensitive(r.is_active());
|
||||
t.set_sensitive(r.is_active());
|
||||
sb.set_sensitive(r.is_active());
|
||||
gg.set_sensitive(r.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
pad_forward_row.set_active(s.gamepad_forwarding);
|
||||
pad_row.set_selected(index::gamepad(s));
|
||||
sysbtn_row.set_selected(index::system_buttons(s));
|
||||
gesture_row.set_selected(index::guide_gesture(s));
|
||||
let touch_i = index::touch(s);
|
||||
touch_row.set_selected(touch_i);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
|
||||
index::surround
|
||||
);
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||
choice!(
|
||||
sysbtn_row,
|
||||
"system_buttons",
|
||||
o.system_buttons.is_some(),
|
||||
index::system_buttons
|
||||
);
|
||||
choice!(
|
||||
gesture_row,
|
||||
"guide_gesture",
|
||||
o.guide_gesture.is_some(),
|
||||
index::guide_gesture
|
||||
);
|
||||
toggle!(
|
||||
pad_forward_row,
|
||||
"gamepad_forwarding",
|
||||
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers_group.add(sysbtn_row.widget());
|
||||
controllers_group.add(gesture_row.widget());
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
|
||||
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.system_buttons = SYSTEM_BUTTONS
|
||||
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
|
||||
.to_string();
|
||||
s.guide_gesture = GUIDE_GESTURES
|
||||
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
|
||||
.to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
|
||||
@@ -18,6 +18,78 @@
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
|
||||
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
|
||||
/// `ok\n` / `err\n` back.
|
||||
///
|
||||
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
|
||||
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
|
||||
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
|
||||
/// reach a flatpak-run session.
|
||||
#[cfg(all(unix, any(target_os = "linux", windows)))]
|
||||
mod ctl_socket {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
|
||||
if let Ok(id) = std::env::var("FLATPAK_ID") {
|
||||
p.push("app");
|
||||
p.push(id);
|
||||
}
|
||||
Some(p.join("punktfunk-session-ctl.sock"))
|
||||
}
|
||||
|
||||
/// Bind + serve on a background thread, once per process (later calls no-op). Any
|
||||
/// failure just logs at debug — the socket is a convenience surface, never worth
|
||||
/// failing a stream over.
|
||||
pub(crate) fn spawn(gamepad: GamepadService) {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(move || {
|
||||
let Some(path) = path() else { return };
|
||||
// A previous session's socket file refuses the bind — it's ours to replace.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = match UnixListener::bind(&path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pf-session-ctl".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut s) = stream else { continue };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(&s).read_line(&mut line).is_err() {
|
||||
continue;
|
||||
}
|
||||
let ok = match line.trim() {
|
||||
"guide" => {
|
||||
gamepad.tap_guide();
|
||||
true
|
||||
}
|
||||
"qam" => {
|
||||
gamepad.tap_qam();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::debug!(error = %e, "session ctl thread failed to start");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
@@ -44,14 +116,20 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
pub(crate) fn gaming_mode() -> bool {
|
||||
std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
arg_flag("--fullscreen") || gaming_mode()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
@@ -194,6 +272,20 @@ mod session_main {
|
||||
// it back. It goes on before the attach below, so a non-forwarding session never opens
|
||||
// — never grabs — the device.
|
||||
gamepad.set_forwarding(settings.gamepad_forwarding);
|
||||
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
|
||||
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
|
||||
// local Steam UI reacts to the same physical buttons there no matter what, so
|
||||
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
|
||||
// unconditionally for the same browse-mode-reuse reason as the line above.
|
||||
let game_mode = gaming_mode();
|
||||
gamepad.set_system_buttons(
|
||||
settings.system_buttons_forward(game_mode),
|
||||
settings.guide_gesture_enabled(game_mode),
|
||||
);
|
||||
// The control socket (guide/QAM injection — the Decky panel's host buttons).
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
|
||||
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
|
||||
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
|
||||
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
|
||||
/// Automatic forwards on desktop and stays local under Gaming Mode.
|
||||
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
|
||||
("auto", "Automatic"),
|
||||
("forward", "Send to host"),
|
||||
("local", "This device"),
|
||||
];
|
||||
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
|
||||
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
|
||||
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
|
||||
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
@@ -479,6 +490,8 @@ struct OverrideFlags {
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
gamepad_forwarding: bool,
|
||||
system_buttons: bool,
|
||||
guide_gesture: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
present_priority: bool,
|
||||
@@ -512,6 +525,8 @@ impl OverrideFlags {
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
gamepad_forwarding: o.gamepad_forwarding.is_some(),
|
||||
system_buttons: o.system_buttons.is_some(),
|
||||
guide_gesture: o.guide_gesture.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
present_priority: o.present_priority.is_some(),
|
||||
@@ -977,6 +992,28 @@ pub(crate) fn settings_page(
|
||||
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
|
||||
let sysbtn_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
sysbtn_names,
|
||||
sysbtn_i,
|
||||
|s, i| {
|
||||
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
|
||||
let gesture_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
gesture_names,
|
||||
gesture_i,
|
||||
|s, i| {
|
||||
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
@@ -1407,6 +1444,30 @@ pub(crate) fn settings_page(
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
motion.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"system_buttons",
|
||||
"Steam / guide button",
|
||||
over.system_buttons,
|
||||
sysbtn_combo,
|
||||
"Where the guide (Xbox/PS) and quick-access presses go while \
|
||||
streaming. Automatic sends them to the host \u{2014} except on \
|
||||
devices whose own overlay reacts to the same press (Gaming Mode), \
|
||||
where they stay local and the gesture below reaches the host.",
|
||||
)),
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"guide_gesture",
|
||||
"Hold Select for guide",
|
||||
over.guide_gesture,
|
||||
gesture_combo,
|
||||
"Hold Select on its own to press the host's guide button \u{2014} keep \
|
||||
holding for a Gaming-Mode host's quick-access menu. A Select tap \
|
||||
still goes through, slightly delayed. Automatic arms it only where \
|
||||
the real button can't reach the host.",
|
||||
)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
|
||||
@@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the
|
||||
/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down
|
||||
/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a
|
||||
/// Gaming-Mode host). Exists because on some platforms the physical guide press can never
|
||||
/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or
|
||||
/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]).
|
||||
///
|
||||
/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture
|
||||
/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons
|
||||
const GUIDE_HOLD: Duration = Duration::from_millis(350);
|
||||
|
||||
/// A held-back Select TAP is delivered as a press with its release scheduled this far
|
||||
/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState`
|
||||
/// snapshots by the core input task, and a down+up inside one fold window can coalesce
|
||||
/// into no press at all.
|
||||
const TAP_PRESS: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
|
||||
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
|
||||
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
|
||||
@@ -285,6 +302,21 @@ fn set_valve_hidapi(enabled: bool) {
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
|
||||
}
|
||||
|
||||
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
|
||||
/// pre-`SDL_Init` hints, not after a subsystem is up.
|
||||
///
|
||||
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
|
||||
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
|
||||
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
|
||||
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
|
||||
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
|
||||
/// order; the caller-pumped path could not, because by the time it receives a
|
||||
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
|
||||
/// its callers can put in the right place.
|
||||
pub fn preinit_disable_valve_hidapi() {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
@@ -337,6 +369,8 @@ enum Ctl {
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
SystemButtons { forward_raw: bool, gesture: bool },
|
||||
TapButton(u32),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -393,9 +427,12 @@ impl GamepadService {
|
||||
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
|
||||
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
|
||||
///
|
||||
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
|
||||
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
|
||||
/// for the duration of an attached session only.
|
||||
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
|
||||
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
|
||||
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
|
||||
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
|
||||
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
|
||||
/// its own it only detaches a driver that has already done the damage.
|
||||
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
|
||||
set_valve_hidapi(false);
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -503,6 +540,39 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::Forwarding(on));
|
||||
}
|
||||
|
||||
/// The session's system-button policy, resolved from
|
||||
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
|
||||
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
|
||||
/// with the local shell — the Gaming-Mode default, where Steam reacts to them no
|
||||
/// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select
|
||||
/// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide —
|
||||
/// and, held longer, a Gaming-Mode host's QAM — reachable from a controller.
|
||||
///
|
||||
/// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward
|
||||
/// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled
|
||||
pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) {
|
||||
let _ = self.ctl.send(Ctl::SystemButtons {
|
||||
forward_raw,
|
||||
gesture,
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now,
|
||||
/// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none
|
||||
/// is open). The session control socket's "press the host's Steam/guide button" verb
|
||||
/// — the Decky panel's UI route to the host overlay. No-op while no session is
|
||||
/// attached.
|
||||
pub fn tap_guide(&self) {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE));
|
||||
}
|
||||
|
||||
/// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`).
|
||||
/// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other
|
||||
/// virtual pads map it to their own misc button (or drop it) — harmless.
|
||||
pub fn tap_qam(&self) {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -552,10 +622,43 @@ impl GamepadPump {
|
||||
/// chord-hold and haptics inside the threaded worker's tolerances).
|
||||
pub fn tick(&mut self) {
|
||||
let _ = self.worker.drain_ctl(&self.ctl_rx);
|
||||
self.worker.gesture_poll();
|
||||
self.worker.maybe_fire_disconnect();
|
||||
self.worker.menu_poll();
|
||||
self.worker.render_feedback();
|
||||
}
|
||||
|
||||
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
|
||||
/// and physically silence it. Call once on the way out of the caller's event loop.
|
||||
///
|
||||
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
|
||||
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
|
||||
/// when the pump next drains it. An exit path that detached and then left the loop without
|
||||
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
|
||||
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
|
||||
///
|
||||
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
|
||||
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
|
||||
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
|
||||
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
|
||||
///
|
||||
/// Idempotent, and safe with nothing attached.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.worker.close_all_slots();
|
||||
}
|
||||
}
|
||||
|
||||
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
|
||||
/// or present error — several paths do — and those would skip an explicit
|
||||
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
|
||||
///
|
||||
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
|
||||
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
|
||||
/// Doing both is free — `shutdown` is idempotent.
|
||||
impl Drop for GamepadPump {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
|
||||
@@ -698,6 +801,9 @@ struct Slot {
|
||||
/// close lift a click held across detach/unplug.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||
/// `guide_gesture` policy is on.
|
||||
gesture: SelectGesture,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -713,6 +819,7 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +830,98 @@ impl Slot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions
|
||||
/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs —
|
||||
/// so the timing rules are testable without SDL or a live session.
|
||||
///
|
||||
/// The rules:
|
||||
/// - Select pressed ALONE is held back (pending). Any other button already down means
|
||||
/// Select is part of a combo — the escape chord ends in it — and passes through.
|
||||
/// - A button pressed WHILE Select is pending makes it a real Select after all; its
|
||||
/// deferred down goes out first, preserving chronology.
|
||||
/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases.
|
||||
/// - Released before the threshold, it's a TAP: press delivered on release, the release
|
||||
/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing).
|
||||
#[derive(Default)]
|
||||
struct SelectGesture {
|
||||
/// Select is down and held back — tap-or-guide undecided.
|
||||
pending_since: Option<Instant>,
|
||||
/// The held-back Select became a synthetic guide; its release lifts the guide.
|
||||
as_guide: bool,
|
||||
/// A delivered tap's release is owed at this time.
|
||||
release_due: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SelectGesture {
|
||||
/// Select went down (`alone` = no other button held on this slot). Returns true when
|
||||
/// the press is held back; false lets the caller forward it as a normal button.
|
||||
fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool {
|
||||
// A previous tap's scheduled release still owed: lift it before the new press.
|
||||
if self.release_due.take().is_some() {
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
if alone {
|
||||
self.pending_since = Some(now);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Another button went down on this slot: a pending Select is a real Select after
|
||||
/// all — its deferred down goes out before the caller sends the new button's.
|
||||
fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) {
|
||||
if self.pending_since.take().is_some() {
|
||||
out.push((wire::BTN_BACK, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Select released. Returns true when the gesture owned this release (the caller
|
||||
/// skips the normal button-up send).
|
||||
fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool {
|
||||
if self.as_guide {
|
||||
self.as_guide = false;
|
||||
out.push((wire::BTN_GUIDE, false));
|
||||
return true;
|
||||
}
|
||||
if self.pending_since.take().is_some() {
|
||||
// A tap: deliver the held-back press now, its release TAP_PRESS behind.
|
||||
out.push((wire::BTN_BACK, true));
|
||||
self.release_due = Some(now + TAP_PRESS);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clock-driven work: the hold threshold and the owed tap release.
|
||||
fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) {
|
||||
if let Some(since) = self.pending_since {
|
||||
if now.duration_since(since) >= GUIDE_HOLD {
|
||||
self.pending_since = None;
|
||||
self.as_guide = true;
|
||||
out.push((wire::BTN_GUIDE, true));
|
||||
}
|
||||
}
|
||||
if let Some(due) = self.release_due {
|
||||
if now >= due {
|
||||
self.release_due = None;
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot close / gesture disarm: nothing may stay down (or owed) on the wire.
|
||||
fn flush(&mut self, out: &mut Vec<(u32, bool)>) {
|
||||
self.pending_since = None;
|
||||
if self.as_guide {
|
||||
self.as_guide = false;
|
||||
out.push((wire::BTN_GUIDE, false));
|
||||
}
|
||||
if self.release_due.take().is_some() {
|
||||
out.push((wire::BTN_BACK, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
@@ -750,6 +949,14 @@ struct Worker {
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
/// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps
|
||||
/// them with the local shell.
|
||||
system_forward: bool,
|
||||
/// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]).
|
||||
guide_gesture: bool,
|
||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -1003,6 +1210,7 @@ impl Worker {
|
||||
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||
// expected for an already-unplugged pad.
|
||||
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||
Self::reset_slot_feedback(&mut self.slots[i]);
|
||||
if let Some(c) = self.attached.clone() {
|
||||
Self::flush_slot(&c, &mut self.slots[i]);
|
||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||
@@ -1018,6 +1226,35 @@ impl Worker {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hand the physical controller back in a neutral state before its handle closes.
|
||||
///
|
||||
/// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an
|
||||
/// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive
|
||||
/// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance
|
||||
/// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but
|
||||
/// another game. Apple's client already resets on teardown; this is the desktop half.
|
||||
///
|
||||
/// Best-effort throughout: the pad may already be gone (that is one of the ways we get here).
|
||||
fn reset_slot_feedback(slot: &mut Slot) {
|
||||
if matches!(
|
||||
slot.pref,
|
||||
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||
) {
|
||||
// An all-zero trigger block is mode 0x00 — no effect — which is what releases the
|
||||
// trigger. Both sides, then the lightbar dark and the player indicator clear.
|
||||
for which in [0u8, 1] {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11]));
|
||||
}
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0));
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0));
|
||||
} else {
|
||||
// Anything else with an LED goes dark through SDL, which owns the per-device details.
|
||||
let _ = slot.pad.set_led(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_all_slots(&mut self) {
|
||||
while !self.slots.is_empty() {
|
||||
self.close_slot_at(0);
|
||||
@@ -1051,6 +1288,14 @@ impl Worker {
|
||||
/// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad.
|
||||
fn flush_slot(c: &NativeClient, slot: &mut Slot) {
|
||||
let pad = slot.index;
|
||||
// Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below
|
||||
// would never lift it — and a still-pending Select was never sent, so dropping
|
||||
// it beats delivering a ghost press into the close.
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.flush(&mut due);
|
||||
for (b, down) in due {
|
||||
send(c, InputKind::GamepadButton, b, down as i32, pad);
|
||||
}
|
||||
for b in slot.held_buttons.drain(..) {
|
||||
send(c, InputKind::GamepadButton, b, 0, pad);
|
||||
}
|
||||
@@ -1128,6 +1373,36 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases —
|
||||
/// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached)
|
||||
/// of jitter.
|
||||
fn gesture_poll(&mut self) {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
self.synthetic_ups.clear();
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// Owed releases of synthetic taps (the control socket's guide/QAM verbs).
|
||||
self.synthetic_ups.retain(|&(pad, bit, due)| {
|
||||
if now >= due {
|
||||
send(&c, InputKind::GamepadButton, bit, 0, pad);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.guide_gesture {
|
||||
return;
|
||||
}
|
||||
for slot in &mut self.slots {
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.poll(now, &mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the disconnect signal once the escape chord has been continuously held past
|
||||
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
|
||||
fn maybe_fire_disconnect(&mut self) {
|
||||
@@ -1305,6 +1580,41 @@ impl Worker {
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::SystemButtons {
|
||||
forward_raw,
|
||||
gesture,
|
||||
}) => {
|
||||
self.system_forward = forward_raw;
|
||||
if self.guide_gesture == gesture {
|
||||
continue;
|
||||
}
|
||||
self.guide_gesture = gesture;
|
||||
// A mid-session flip may strand gesture state — a synthetic guide
|
||||
// still down, an owed tap release — lift it now (no-op on the way on:
|
||||
// an unarmed gesture was never fed).
|
||||
if let Some(c) = self.attached.clone() {
|
||||
for slot in &mut self.slots {
|
||||
let mut due = Vec::new();
|
||||
slot.gesture.flush(&mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ctl::TapButton(bit)) => {
|
||||
// Synthetic system-button tap (the session control socket): down on
|
||||
// the first forwarded slot's index — pad 0 when none is open (a
|
||||
// forwarding-off session; best-effort there, the wire pad may not
|
||||
// exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS
|
||||
// later, so the pair can't fold into nothing.
|
||||
if let Some(c) = self.attached.clone() {
|
||||
let pad = self.slots.first().map_or(0, |s| s.index);
|
||||
send(&c, InputKind::GamepadButton, bit, 1, pad);
|
||||
self.synthetic_ups
|
||||
.push((pad, bit, Instant::now() + TAP_PRESS));
|
||||
}
|
||||
}
|
||||
Ok(Ctl::Forwarding(on)) => {
|
||||
if self.forwarding == on {
|
||||
continue;
|
||||
@@ -1405,8 +1715,32 @@ impl Worker {
|
||||
return;
|
||||
}
|
||||
if let Some(bit) = button_bit(button) {
|
||||
// Raw system buttons stay with the local shell when passthrough is
|
||||
// off (the Gaming-Mode default): Steam already opened ITS overlay
|
||||
// for this press; the host's is reached via the hold-Select gesture
|
||||
// (and the Decky panel) instead.
|
||||
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
|
||||
return;
|
||||
}
|
||||
let mut due = Vec::new();
|
||||
let held_back = if !self.guide_gesture {
|
||||
false
|
||||
} else if bit == wire::BTN_BACK {
|
||||
let alone = slot.held_buttons.is_empty();
|
||||
slot.gesture.on_select_down(Instant::now(), alone, &mut due)
|
||||
} else {
|
||||
slot.gesture.on_other_down(&mut due);
|
||||
false
|
||||
};
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
// Held-back or not, the chord bookkeeping sees the physical press —
|
||||
// the escape chord must not care that the gesture exists.
|
||||
slot.held_buttons.push(bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
|
||||
if !held_back {
|
||||
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
|
||||
}
|
||||
self.maybe_fire_escape();
|
||||
}
|
||||
}
|
||||
@@ -1422,8 +1756,20 @@ impl Worker {
|
||||
return;
|
||||
}
|
||||
if let Some(bit) = button_bit(button) {
|
||||
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
|
||||
return;
|
||||
}
|
||||
slot.held_buttons.retain(|&b| b != bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
|
||||
let mut due = Vec::new();
|
||||
let owned = self.guide_gesture
|
||||
&& bit == wire::BTN_BACK
|
||||
&& slot.gesture.on_select_up(Instant::now(), &mut due);
|
||||
for (b, down) in due {
|
||||
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
|
||||
}
|
||||
if !owned {
|
||||
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
|
||||
}
|
||||
self.rearm_escape();
|
||||
}
|
||||
}
|
||||
@@ -1626,6 +1972,11 @@ impl Worker {
|
||||
HidOutput::PlayerLeds { bits, .. } if is_ds => {
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
// Every other pad with player LEDs gets them through SDL, which owns the
|
||||
// per-device pattern. This used to fall through and do nothing at all.
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
let _ = set_player_leds(&slot.pad, bits);
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
which, ref effect, ..
|
||||
} if is_ds => {
|
||||
@@ -1633,12 +1984,43 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||
// variant cannot join them silently: adaptive triggers exist only on a
|
||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||
// and carried by `send_effect` above when the pad is one.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
|
||||
///
|
||||
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
|
||||
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
|
||||
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
|
||||
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
|
||||
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
|
||||
///
|
||||
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
|
||||
/// device, so nothing that takes one can be.
|
||||
fn player_index_from_bits(bits: u8) -> Option<u16> {
|
||||
match (bits & 0x1F).count_ones() {
|
||||
0 => None,
|
||||
n => Some((n - 1) as u16),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
|
||||
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
|
||||
match player_index_from_bits(bits) {
|
||||
None => pad.unset_player_index(),
|
||||
Some(i) => pad.set_player_index(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
|
||||
fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
match h {
|
||||
@@ -1671,6 +2053,9 @@ impl Worker {
|
||||
pinned: None,
|
||||
forwarding: true,
|
||||
kind_override: GamepadPref::Auto,
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -1742,6 +2127,7 @@ fn run(
|
||||
|
||||
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.gesture_poll();
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
w.menu_poll();
|
||||
@@ -1749,6 +2135,115 @@ fn run(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_gesture_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tap_delivers_press_then_scheduled_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out), "not held back");
|
||||
assert!(out.is_empty(), "a held-back press sends nothing yet");
|
||||
// Released inside the threshold: the press goes out on release…
|
||||
let up = t + Duration::from_millis(120);
|
||||
assert!(g.on_select_up(up, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
|
||||
out.clear();
|
||||
// …and the release only TAP_PRESS behind it, so the pair can't fold away.
|
||||
g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out);
|
||||
assert!(out.is_empty(), "release went out early");
|
||||
g.poll(up + TAP_PRESS, &mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_becomes_guide_down_until_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out);
|
||||
assert!(out.is_empty(), "guide fired inside the threshold");
|
||||
g.poll(t + GUIDE_HOLD, &mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, true)]);
|
||||
out.clear();
|
||||
// Held on: nothing more (the host times its own long-press = QAM).
|
||||
g.poll(t + GUIDE_HOLD * 4, &mut out);
|
||||
assert!(out.is_empty());
|
||||
// Release lifts the guide, never a Select.
|
||||
assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_button_makes_pending_select_real() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
// A joins inside the window: the deferred Select down goes out first (the
|
||||
// caller then sends A's own down — chronology preserved).
|
||||
g.on_other_down(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
|
||||
out.clear();
|
||||
// The release is a normal button-up now — the gesture doesn't own it.
|
||||
assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out));
|
||||
assert!(out.is_empty());
|
||||
// And no stale guide fires later.
|
||||
g.poll(t + GUIDE_HOLD * 2, &mut out);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_inside_a_combo_passes_through() {
|
||||
let mut g = SelectGesture::default();
|
||||
let mut out = Vec::new();
|
||||
// L1+R1+Start already down (the escape chord ends in Select): not held back.
|
||||
assert!(!g.on_select_down(Instant::now(), false, &mut out));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_repress_lifts_owed_release_first() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
|
||||
out.clear();
|
||||
// Re-pressed before the owed release fired: the up goes out before the new
|
||||
// press is held back — the host never sees two downs in a row.
|
||||
assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out));
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_lifts_synthetic_guide_and_owed_release() {
|
||||
let mut g = SelectGesture::default();
|
||||
let t = Instant::now();
|
||||
let mut out = Vec::new();
|
||||
// Transformed hold: flush lifts the guide.
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.poll(t + GUIDE_HOLD, &mut out);
|
||||
out.clear();
|
||||
g.flush(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
|
||||
out.clear();
|
||||
// Owed tap release: flush emits it. A pending (never-sent) Select just drops.
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
|
||||
out.clear();
|
||||
g.flush(&mut out);
|
||||
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
|
||||
out.clear();
|
||||
assert!(g.on_select_down(t, true, &mut out));
|
||||
g.flush(&mut out);
|
||||
assert!(out.is_empty(), "a never-sent pending Select ghosted a send");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod menu_nav_tests {
|
||||
use super::*;
|
||||
@@ -2008,3 +2503,86 @@ mod slot_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reset_packet_tests {
|
||||
use super::*;
|
||||
|
||||
/// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this
|
||||
/// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong
|
||||
/// enable flag or a non-zero mode byte would silently leave the effect latched, which is the
|
||||
/// bug this reset exists to prevent.
|
||||
#[test]
|
||||
fn reset_packets_release_the_triggers_and_darken_the_lights() {
|
||||
// Trigger release: mode 0x00 with no parameters, on the side's own enable bit.
|
||||
let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]);
|
||||
assert_eq!(l[0], 0x08, "left-trigger enable bit");
|
||||
assert!(
|
||||
l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0),
|
||||
"an all-zero block is mode 0x00 = no effect"
|
||||
);
|
||||
let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]);
|
||||
assert_eq!(r[0], 0x04, "right-trigger enable bit");
|
||||
assert!(
|
||||
r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
|
||||
// Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad
|
||||
// ignores the payload and keeps the game's last colour.
|
||||
let bar = Ds5Feedback::lightbar_packet(0, 0, 0);
|
||||
assert_eq!(bar[1], 0x04, "lightbar enable bit");
|
||||
assert_eq!(
|
||||
&bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3],
|
||||
&[0, 0, 0]
|
||||
);
|
||||
|
||||
// Player indicator cleared.
|
||||
let pl = Ds5Feedback::player_packet(0);
|
||||
assert_eq!(pl[1], 0x10, "player-LED enable bit");
|
||||
assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod player_led_tests {
|
||||
use super::*;
|
||||
|
||||
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
|
||||
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
|
||||
/// otherwise only obvious once you have seen both patterns side by side.
|
||||
#[test]
|
||||
fn player_index_counts_lit_leds_for_both_conventions() {
|
||||
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
|
||||
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
|
||||
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
|
||||
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
|
||||
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
|
||||
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
|
||||
|
||||
// Switch/XInput style — a contiguous run of low bits, the same count each time.
|
||||
assert_eq!(player_index_from_bits(0x01), Some(0));
|
||||
assert_eq!(player_index_from_bits(0x03), Some(1));
|
||||
assert_eq!(player_index_from_bits(0x07), Some(2));
|
||||
assert_eq!(player_index_from_bits(0x0F), Some(3));
|
||||
}
|
||||
|
||||
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
|
||||
#[test]
|
||||
fn no_lit_led_is_no_player() {
|
||||
assert_eq!(player_index_from_bits(0x00), None);
|
||||
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
|
||||
assert_eq!(player_index_from_bits(0xE0), None);
|
||||
}
|
||||
|
||||
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
|
||||
/// the 5 real LEDs.
|
||||
#[test]
|
||||
fn high_bits_are_masked_off_before_counting() {
|
||||
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
|
||||
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad_forwarding: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_buttons: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guide_gesture: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
@@ -159,6 +163,12 @@ impl SettingsOverlay {
|
||||
if let Some(v) = self.gamepad_forwarding {
|
||||
s.gamepad_forwarding = v;
|
||||
}
|
||||
if let Some(v) = &self.system_buttons {
|
||||
s.system_buttons = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.guide_gesture {
|
||||
s.guide_gesture = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
@@ -252,6 +262,12 @@ impl SettingsOverlay {
|
||||
if after.gamepad_forwarding != before.gamepad_forwarding {
|
||||
self.gamepad_forwarding = Some(after.gamepad_forwarding);
|
||||
}
|
||||
if after.system_buttons != before.system_buttons {
|
||||
self.system_buttons = Some(after.system_buttons.clone());
|
||||
}
|
||||
if after.guide_gesture != before.guide_gesture {
|
||||
self.guide_gesture = Some(after.guide_gesture.clone());
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
@@ -302,6 +318,8 @@ impl SettingsOverlay {
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"gamepad_forwarding" => self.gamepad_forwarding = None,
|
||||
"system_buttons" => self.system_buttons = None,
|
||||
"guide_gesture" => self.guide_gesture = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
"present_priority" => self.present_priority = None,
|
||||
@@ -506,6 +524,8 @@ mod tests {
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
gamepad_forwarding: Some(false),
|
||||
system_buttons: Some("local".into()),
|
||||
guide_gesture: Some("on".into()),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
@@ -532,6 +552,8 @@ mod tests {
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(!out.gamepad_forwarding);
|
||||
assert_eq!(out.system_buttons, "local");
|
||||
assert_eq!(out.guide_gesture, "on");
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
|
||||
@@ -879,6 +879,25 @@ pub struct Settings {
|
||||
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
|
||||
/// gamepad service at startup so the choice survives restarts.
|
||||
pub forward_pad: String,
|
||||
/// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` —
|
||||
/// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host,
|
||||
/// the pre-setting behaviour), or `"local"` (they stay with this device; the host's
|
||||
/// are reached via the hold-Select gesture instead). Auto resolves per platform in
|
||||
/// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode,
|
||||
/// where the local Steam UI always reacts to the same physical press — forwarding
|
||||
/// there opens BOTH overlays, the local one on top of the stream.
|
||||
#[serde(default = "default_auto")]
|
||||
pub system_buttons: String,
|
||||
/// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST
|
||||
/// the guide button (down for as long as it's held, so a long hold is the host's
|
||||
/// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`,
|
||||
/// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw
|
||||
/// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own
|
||||
/// auto in the Apple client). While armed, a Select TAP is delivered on release —
|
||||
/// costing it up to the hold threshold in latency — and a Select held as part of a
|
||||
/// combo (any other button already down) passes through untouched.
|
||||
#[serde(default = "default_auto")]
|
||||
pub guide_gesture: String,
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
@@ -1032,6 +1051,10 @@ fn default_codec() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_auto() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
@@ -1081,6 +1104,29 @@ impl Settings {
|
||||
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
|
||||
}
|
||||
|
||||
/// Whether raw system-button presses (guide + QAM) are forwarded to the host.
|
||||
/// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope),
|
||||
/// where the local Steam UI reacts to the same physical buttons no matter what we
|
||||
/// do — auto keeps them local there and forwards everywhere else.
|
||||
pub fn system_buttons_forward(&self, game_mode: bool) -> bool {
|
||||
match self.system_buttons.as_str() {
|
||||
"forward" => true,
|
||||
"local" => false,
|
||||
_ => !game_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]).
|
||||
/// Auto = on only under Gaming Mode, where it is the sole controller route to the
|
||||
/// host's guide once raw presses stay local.
|
||||
pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool {
|
||||
match self.guide_gesture.as_str() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => game_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -1107,6 +1153,8 @@ impl Default for Settings {
|
||||
gamepad: "auto".into(),
|
||||
gamepad_forwarding: true,
|
||||
forward_pad: String::new(),
|
||||
system_buttons: "auto".into(),
|
||||
guide_gesture: "auto".into(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
mouse_mode: "capture".into(),
|
||||
|
||||
@@ -41,6 +41,8 @@ enum RowId {
|
||||
PadForward,
|
||||
Pad,
|
||||
PadType,
|
||||
SystemButtons,
|
||||
GuideGesture,
|
||||
Touch,
|
||||
Mouse,
|
||||
InvertScroll,
|
||||
@@ -57,7 +59,7 @@ enum RowId {
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 27] = [
|
||||
const ROWS: [RowId; 29] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
@@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
@@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [
|
||||
("dualshock4", "DualShock 4"),
|
||||
("steamdeck", "Steam Deck"),
|
||||
];
|
||||
/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the
|
||||
/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the
|
||||
/// local Steam UI reacts to the same press and both overlays would open at once.
|
||||
const SYSTEM_BUTTONS: [(&str, &str); 3] = [
|
||||
("auto", "Automatic"),
|
||||
("forward", "Send to host"),
|
||||
("local", "This device"),
|
||||
];
|
||||
/// The hold-Select guide gesture — the shared `guide_gesture` key.
|
||||
const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")];
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
@@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// move everything under the cursor).
|
||||
let enabled = match id {
|
||||
RowId::EchoCancel => s.mic_enabled,
|
||||
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
|
||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||
s.gamepad_forwarding
|
||||
}
|
||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||
_ => true,
|
||||
};
|
||||
@@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Controller type",
|
||||
label_for(&PAD_TYPES, &s.gamepad).into(),
|
||||
),
|
||||
RowId::SystemButtons => (
|
||||
None,
|
||||
"Steam / guide button",
|
||||
label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(),
|
||||
),
|
||||
RowId::GuideGesture => (
|
||||
None,
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::Touch => (
|
||||
Some("Touchscreen"),
|
||||
"Touch mode",
|
||||
@@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||
RowId::SystemButtons => {
|
||||
"Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \
|
||||
sends them to the host except in Gaming Mode, where Steam on this device \
|
||||
reacts to the same press and both overlays would open at once."
|
||||
}
|
||||
RowId::GuideGesture => {
|
||||
"Hold Select on its own to press the host's guide button — keep holding for \
|
||||
the host's quick-access menu. Automatic arms it only where the real button \
|
||||
can't reach the host. A Select tap still goes through, slightly delayed."
|
||||
}
|
||||
RowId::Touch => {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
@@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
}
|
||||
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
|
||||
}
|
||||
RowId::SystemButtons => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap)
|
||||
}
|
||||
RowId::GuideGesture => {
|
||||
if !s.gamepad_forwarding {
|
||||
return false;
|
||||
}
|
||||
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
|
||||
}
|
||||
RowId::Touch => {
|
||||
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the latched rich state is re-emitted even though nothing changed.
|
||||
///
|
||||
/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is
|
||||
/// forwarded exactly once, so if that datagram is dropped the game will never produce it again —
|
||||
/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left
|
||||
/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as
|
||||
/// long as the game keeps that setting. For a trigger effect that can be the rest of a level.
|
||||
///
|
||||
/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update
|
||||
/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four
|
||||
/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
|
||||
const RENEW_EVERY: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
@@ -18,6 +32,9 @@ pub struct HidoutDedup {
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
|
||||
/// there is nothing to renew. See [`RENEW_EVERY`].
|
||||
last_sent: Option<Instant>,
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
@@ -29,7 +46,53 @@ impl HidoutDedup {
|
||||
|
||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||
///
|
||||
/// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so
|
||||
/// a plane the game is actively changing never pays for a renewal it does not need.
|
||||
pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool {
|
||||
let fwd = self.decide(h);
|
||||
if fwd {
|
||||
self.last_sent = Some(now);
|
||||
}
|
||||
fwd
|
||||
}
|
||||
|
||||
/// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous
|
||||
/// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything
|
||||
/// last went out); every one is idempotent, so a client that DID receive the original simply
|
||||
/// re-applies it.
|
||||
///
|
||||
/// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new*
|
||||
/// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh
|
||||
/// cadence (see the note in [`Self::decide`]).
|
||||
pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec<HidOutput> {
|
||||
if self
|
||||
.last_sent
|
||||
.is_none_or(|t| now.duration_since(t) < RENEW_EVERY)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.last_sent = Some(now);
|
||||
let mut out = Vec::new();
|
||||
if let Some((r, g, b)) = self.led {
|
||||
out.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if let Some(bits) = self.player_leds {
|
||||
out.push(HidOutput::PlayerLeds { pad, bits });
|
||||
}
|
||||
for (which, effect) in self.trigger.iter().enumerate() {
|
||||
if let Some(effect) = effect {
|
||||
out.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: which as u8,
|
||||
effect: effect.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decide(&mut self, h: &HidOutput) -> bool {
|
||||
match h {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
let v = Some((*r, *g, *b));
|
||||
@@ -77,6 +140,7 @@ mod tests {
|
||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||
#[test]
|
||||
fn hidout_dedup_forwards_only_changes() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
@@ -85,15 +149,15 @@ mod tests {
|
||||
b: 0,
|
||||
};
|
||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||
assert!(d.should_forward(&led(10)));
|
||||
assert!(!d.should_forward(&led(10)));
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&led(10), t));
|
||||
assert!(!d.should_forward(&led(10), t));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
|
||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&pl(0b101), t));
|
||||
assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged
|
||||
|
||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||
let trig = |which, byte| HidOutput::Trigger {
|
||||
@@ -101,10 +165,10 @@ mod tests {
|
||||
which,
|
||||
effect: vec![byte, 0, 0],
|
||||
};
|
||||
assert!(d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||
assert!(d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed
|
||||
|
||||
// One-shot haptic pulses are never deduped.
|
||||
let haptic = HidOutput::TrackpadHaptic {
|
||||
@@ -114,13 +178,128 @@ mod tests {
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
assert!(d.should_forward(&haptic, t));
|
||||
|
||||
// `clear` re-arms every kind.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
assert!(d.should_forward(&led(20), t));
|
||||
assert!(d.should_forward(&pl(0b101), t));
|
||||
assert!(d.should_forward(&trig(0, 2), t));
|
||||
}
|
||||
|
||||
/// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else
|
||||
/// would ever carry it. The renewal is what repairs that.
|
||||
#[test]
|
||||
fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
let trig = HidOutput::Trigger {
|
||||
pad: 3,
|
||||
which: 1,
|
||||
effect: vec![0x02, 0x90, 0xA0],
|
||||
};
|
||||
assert!(d.should_forward(&trig, t));
|
||||
assert!(
|
||||
!d.should_forward(&trig, t),
|
||||
"the game re-sends it; the dedup swallows it"
|
||||
);
|
||||
|
||||
// Nothing due yet.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty());
|
||||
|
||||
// Past the window: the latched state goes out again, addressed to the right pad.
|
||||
let out = d.renewals(3, t + Duration::from_millis(1000));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(matches!(
|
||||
&out[0],
|
||||
HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0]
|
||||
));
|
||||
|
||||
// And it keeps repairing on the same cadence, not just once.
|
||||
assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty());
|
||||
assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1);
|
||||
}
|
||||
|
||||
/// Every latched plane is renewed together, and a plane the game is actively driving does not
|
||||
/// pay for renewals it does not need (a forward resets the clock).
|
||||
#[test]
|
||||
fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 9,
|
||||
g: 8,
|
||||
b: 7
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::PlayerLeds {
|
||||
pad: 0,
|
||||
bits: 0b100
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 0,
|
||||
effect: vec![1]
|
||||
},
|
||||
t
|
||||
));
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which: 1,
|
||||
effect: vec![2]
|
||||
},
|
||||
t
|
||||
));
|
||||
|
||||
let out = d.renewals(0, t + Duration::from_millis(1000));
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
4,
|
||||
"lightbar + player LEDs + both triggers, got {out:?}"
|
||||
);
|
||||
|
||||
// A genuine change re-stamps the clock, so the next renewal is a full window away.
|
||||
let later = t + Duration::from_millis(1500);
|
||||
assert!(d.should_forward(
|
||||
&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3
|
||||
},
|
||||
later
|
||||
));
|
||||
assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty());
|
||||
assert!(!d
|
||||
.renewals(0, later + Duration::from_millis(1000))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair".
|
||||
#[test]
|
||||
fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() {
|
||||
let t = Instant::now();
|
||||
let mut d = HidoutDedup::default();
|
||||
assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty());
|
||||
|
||||
let pulse = HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 0,
|
||||
amplitude: 1,
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&pulse, t));
|
||||
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
|
||||
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Playback {
|
||||
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
|
||||
/// silent, which is the whole point of the delay.
|
||||
starts: Instant,
|
||||
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
|
||||
ends: Option<Instant>,
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
/// `Some(window)` while playing.
|
||||
playing: Option<Playback>,
|
||||
replay_ms: u16,
|
||||
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
|
||||
/// upload since forever and, until now, never acted on: the effect started immediately and
|
||||
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
|
||||
/// Wine does this routinely) fired early AND finished early by the same amount.
|
||||
delay_ms: u16,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
|
||||
/// rumble (or until stopped, when the length is 0).
|
||||
///
|
||||
/// `replay.length` is measured from the END of the delay, not from the play command, so the
|
||||
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
|
||||
/// purely so this is testable — the handler itself needs a live uinput fd.
|
||||
fn window(&self, at: Instant) -> Playback {
|
||||
let starts = at + Duration::from_millis(self.delay_ms as u64);
|
||||
Playback {
|
||||
starts,
|
||||
ends: (self.replay_ms > 0)
|
||||
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
||||
@@ -299,17 +331,29 @@ impl FfState {
|
||||
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
||||
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
||||
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
||||
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
||||
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
|
||||
let plane_stale = quiet_since(self.last_activity);
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
let Some(deadline) = e.playing else { continue };
|
||||
match deadline {
|
||||
let Some(p) = e.playing else { continue };
|
||||
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
|
||||
// abandoned-effect force-off — it has not had its turn yet.
|
||||
if now < p.starts {
|
||||
continue;
|
||||
}
|
||||
match p.ends {
|
||||
Some(d) if now >= d => e.playing = None,
|
||||
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
||||
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
||||
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
||||
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
||||
None if stale => {
|
||||
//
|
||||
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
|
||||
// the plane quiet: the play command is itself the last activity, so an effect with
|
||||
// a `replay.delay` longer than the window would otherwise be force-stopped the
|
||||
// instant it finally started — silent the whole time it waited, then killed on its
|
||||
// first contributing tick.
|
||||
None if plane_stale && quiet_since(p.starts) => {
|
||||
tracing::info!(
|
||||
strong = e.strong,
|
||||
weak = e.weak,
|
||||
@@ -544,10 +588,12 @@ impl VirtualPad {
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
slot.delay_ms = e.replay_delay;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
@@ -574,14 +620,7 @@ impl VirtualPad {
|
||||
(EV_FF, code) => {
|
||||
self.ff.note_activity();
|
||||
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -802,15 +841,34 @@ mod ff_state_tests {
|
||||
ff
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, until explicitly stopped.
|
||||
fn playing(at: Instant) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Playing from `at`, no delay, for `len`.
|
||||
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
|
||||
Some(Playback {
|
||||
starts: at,
|
||||
ends: Some(at + len),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
// Playing since before the window: "abandoned" means audible AND unattended, so an
|
||||
// effect that only just started is not a candidate however stale the plane is.
|
||||
playing: playing(now - Duration::from_millis(2600)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let now = Instant::now();
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
||||
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
||||
@@ -825,8 +883,9 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x4000,
|
||||
weak: 0,
|
||||
playing: Some(Some(now + Duration::from_secs(10))),
|
||||
playing: playing_for(now, Duration::from_secs(10)),
|
||||
replay_ms: 10_000,
|
||||
delay_ms: 0,
|
||||
});
|
||||
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
||||
// the contract (a real pad honors it too), so it keeps playing…
|
||||
@@ -842,26 +901,135 @@ mod ff_state_tests {
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now - Duration::from_millis(3000)),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
ff.last_activity = now - Duration::from_millis(3000);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
||||
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
||||
ff.last_activity = now;
|
||||
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
||||
ff.effects.get_mut(&0).unwrap().playing = playing(now);
|
||||
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
||||
}
|
||||
|
||||
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
|
||||
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
|
||||
/// started early and finished early — DirectInput under Wine schedules these routinely.
|
||||
#[test]
|
||||
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_millis(500);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback {
|
||||
starts,
|
||||
ends: Some(starts + Duration::from_secs(1)),
|
||||
}),
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
});
|
||||
// Inside the delay: armed but silent.
|
||||
assert_eq!(ff.mix(now, IDLE), None);
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
|
||||
// Delay elapsed: it plays.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(501), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
|
||||
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
|
||||
// And ends at delay + length, not at length.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(1600), IDLE),
|
||||
Some((0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The window a play opens, straight from the uploaded fields — this is the half that reads
|
||||
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
|
||||
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
|
||||
#[test]
|
||||
fn window_offsets_the_whole_playback_by_replay_delay() {
|
||||
let at = Instant::now();
|
||||
|
||||
let delayed = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 500,
|
||||
};
|
||||
let w = delayed.window(at);
|
||||
assert_eq!(
|
||||
w.starts,
|
||||
at + Duration::from_millis(500),
|
||||
"delay defers the start"
|
||||
);
|
||||
assert_eq!(
|
||||
w.ends,
|
||||
Some(at + Duration::from_millis(1500)),
|
||||
"length runs from the END of the delay, so the effect keeps its full second"
|
||||
);
|
||||
|
||||
// No delay: starts immediately, unchanged from before.
|
||||
let plain = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 1000,
|
||||
delay_ms: 0,
|
||||
};
|
||||
let w = plain.window(at);
|
||||
assert_eq!(w.starts, at);
|
||||
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
|
||||
|
||||
// Length 0 = until stopped, but the delay still applies.
|
||||
let infinite = Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
delay_ms: 250,
|
||||
};
|
||||
let w = infinite.window(at);
|
||||
assert_eq!(w.starts, at + Duration::from_millis(250));
|
||||
assert_eq!(w.ends, None);
|
||||
}
|
||||
|
||||
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
|
||||
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
|
||||
#[test]
|
||||
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
|
||||
let now = Instant::now();
|
||||
let starts = now + Duration::from_secs(5);
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(Playback { starts, ends: None }),
|
||||
replay_ms: 0,
|
||||
delay_ms: 5000,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(60); // long stale
|
||||
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
|
||||
// It still plays when its delay elapses.
|
||||
assert_eq!(
|
||||
ff.mix(now + Duration::from_millis(5001), IDLE),
|
||||
Some((scaled(0x8000), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_watchdog_never_cuts() {
|
||||
let now = Instant::now();
|
||||
let mut ff = ff_with(Effect {
|
||||
strong: 0x8000,
|
||||
weak: 0,
|
||||
playing: Some(None),
|
||||
playing: playing(now),
|
||||
replay_ms: 0,
|
||||
delay_ms: 0,
|
||||
});
|
||||
ff.last_activity = now - Duration::from_secs(600);
|
||||
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
||||
|
||||
@@ -250,11 +250,19 @@ impl DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
|
||||
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
|
||||
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
|
||||
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
|
||||
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
|
||||
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
|
||||
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
|
||||
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
ly: to_u8(ly.saturating_neg()),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
ry: to_u8(ry.saturating_neg()),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
@@ -783,6 +791,29 @@ mod tests {
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
|
||||
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
|
||||
/// sub-deadzone tilt. Extremes must stay exact either way.
|
||||
#[test]
|
||||
fn centred_sticks_encode_as_neutral_on_every_axis() {
|
||||
let n = DsState::neutral();
|
||||
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
|
||||
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
|
||||
|
||||
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
|
||||
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
|
||||
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
|
||||
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
|
||||
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
|
||||
|
||||
// X keeps its existing mapping.
|
||||
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
|
||||
assert_eq!((right.lx, right.rx), (255, 255));
|
||||
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
|
||||
assert_eq!((left.lx, left.rx), (0, 0));
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
|
||||
@@ -183,8 +183,9 @@ impl SteamState {
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
|
||||
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
|
||||
/// ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
@@ -200,8 +201,8 @@ impl SteamState {
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
lt: trigger_u16(lt),
|
||||
rt: trigger_u16(rt),
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
|
||||
///
|
||||
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
|
||||
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
|
||||
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
|
||||
///
|
||||
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
|
||||
/// ends against this: `32767 >> 7 == 255`.
|
||||
fn trigger_u16(v: u8) -> u16 {
|
||||
((v as u32 * 32767) / 255) as u16
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
|
||||
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
|
||||
// already has a graceful answer to. Reporting the true length lets its own validation
|
||||
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
|
||||
// documented behaviour for a reply it does not like.
|
||||
let len = bytes.len().min(21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
@@ -704,7 +722,7 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
@@ -730,6 +748,30 @@ mod tests {
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
|
||||
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
|
||||
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
|
||||
#[test]
|
||||
fn empty_serial_reply_does_not_panic() {
|
||||
let r = serial_reply("");
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(
|
||||
r[2], 0,
|
||||
"length the kernel will reject, rather than a panic"
|
||||
);
|
||||
|
||||
// Normal and over-long serials still behave.
|
||||
let r = serial_reply("ABC123");
|
||||
assert_eq!(r[2], 6);
|
||||
assert_eq!(&r[4..10], b"ABC123");
|
||||
let long = "X".repeat(40);
|
||||
assert_eq!(
|
||||
serial_reply(&long)[2],
|
||||
21,
|
||||
"clamped to the protocol maximum"
|
||||
);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
|
||||
@@ -159,6 +159,22 @@ impl OverflowWarn {
|
||||
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
|
||||
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
|
||||
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
|
||||
///
|
||||
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
|
||||
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
|
||||
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
|
||||
/// effect longer than this window is cut in half here. The uinput path
|
||||
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
|
||||
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
|
||||
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
|
||||
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
|
||||
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
|
||||
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
|
||||
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
|
||||
/// HD-rumble decays faster than this window regardless.
|
||||
///
|
||||
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
|
||||
/// titles actually hit; the hatch below exists for exactly that experiment.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
|
||||
@@ -338,10 +354,17 @@ impl<B: PadProto> UhidManager<B> {
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
if self.hidout_dedup[i].should_forward(&h, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
// Re-assert the latched rich state on a slow cadence. Deduping a plane that rides
|
||||
// unreliable datagrams means a dropped update is never re-derived from the game — it
|
||||
// keeps sending the same value and the dedup eats every copy — so without this one
|
||||
// lost datagram leaves the pad on the previous weapon's trigger effect indefinitely.
|
||||
for h in self.hidout_dedup[i].renewals(i as u8, now) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -819,46 +819,77 @@ impl DriverAttach {
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
///
|
||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
||||
///
|
||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
||||
/// report what it actually found rather than "still enumerating".
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
||||
let shm_name = self.shm_name.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-driver-diagnose".into())
|
||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
||||
/// the closure so the blocking calls stay visible as blocking.
|
||||
fn diagnose_blocking(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: &str,
|
||||
instance_id: Option<String>,
|
||||
) {
|
||||
let store = match driver_store_has(inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string(),
|
||||
};
|
||||
tracing::warn!(
|
||||
driver,
|
||||
shm = %shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
||||
/// still running past [`INVENTORY_WAIT`]) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -466,6 +466,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
|
||||
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
|
||||
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
|
||||
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
|
||||
// symptom was the Deck losing its trackpad cursor at the start of every session until the
|
||||
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
|
||||
pf_client_core::gamepad::preinit_disable_valve_hidapi();
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
@@ -1895,6 +1902,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
};
|
||||
|
||||
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
|
||||
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
|
||||
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
|
||||
// the pump drains it. Single mode broke out of the loop immediately after detaching and
|
||||
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
|
||||
// was rumbling at the time, still buzzing.
|
||||
pump.shutdown();
|
||||
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
|
||||
// device would race vkDeviceWaitIdle otherwise.
|
||||
if let Some(st) = stream.take() {
|
||||
|
||||
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(saved) = inner.group.ccd_saved.take() {
|
||||
restore_displays_ccd(&saved);
|
||||
}
|
||||
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
|
||||
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
|
||||
// — which clears it itself — then never runs). The group is gone either way, so no
|
||||
// future host start owes this desk a force-EXTEND.
|
||||
pf_win_display::win_display::isolate_journal::clear();
|
||||
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
|
||||
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
|
||||
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
|
||||
|
||||
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
|
||||
/// a *dead* one did.
|
||||
///
|
||||
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
|
||||
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
|
||||
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
|
||||
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
|
||||
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
|
||||
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
|
||||
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
|
||||
/// or a reboot).
|
||||
///
|
||||
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
|
||||
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
|
||||
/// survived.
|
||||
///
|
||||
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
|
||||
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
|
||||
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
|
||||
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
|
||||
/// every connected display from the OS's own database, needs no struct serialization, and stays
|
||||
/// correct across a reboot — where saved target ids would be stale anyway.
|
||||
pub mod isolate_journal {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
|
||||
/// file every couple of seconds. `None` = "no marker known to be on disk".
|
||||
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
|
||||
|
||||
fn path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("display-isolate-active.json")
|
||||
}
|
||||
|
||||
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
|
||||
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
|
||||
pub fn mark(deactivated: &[u32]) {
|
||||
if deactivated.is_empty() {
|
||||
return; // nothing was deactivated ⇒ nothing for a later host to put back
|
||||
}
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(deactivated) {
|
||||
return;
|
||||
}
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = pf_paths::create_private_dir(dir);
|
||||
}
|
||||
match std::fs::write(
|
||||
&p,
|
||||
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
|
||||
) {
|
||||
Ok(()) => *last = Some(deactivated.to_vec()),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"display isolate: could not write the crash-recovery journal — if this host dies \
|
||||
mid-session the deactivated panels will stay dark"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
|
||||
/// Idempotent; safe to call when no marker exists.
|
||||
pub fn clear() {
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = std::fs::remove_file(path());
|
||||
*last = None;
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
|
||||
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
|
||||
///
|
||||
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
|
||||
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
|
||||
pub fn startup_recover() {
|
||||
let Some(targets) = pending() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
deactivated = ?targets,
|
||||
"display isolate: a previous host exited with the operator's display(s) deactivated for \
|
||||
an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \
|
||||
not left dark"
|
||||
);
|
||||
super::force_extend_topology();
|
||||
clear();
|
||||
}
|
||||
|
||||
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
|
||||
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
|
||||
/// real `SetDisplayConfig` against the machine running the test.
|
||||
pub fn pending() -> Option<Vec<u32>> {
|
||||
let bytes = std::fs::read(path()).ok()?;
|
||||
Some(serde_json::from_slice(&bytes).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
|
||||
/// process-global, so these cases must not interleave.
|
||||
static ENV: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Point the journal at a scratch dir for the duration of one case.
|
||||
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
|
||||
clear(); // reset the LAST cache + any leftover marker from a previous run
|
||||
f(&dir);
|
||||
clear();
|
||||
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The crash path: a host marks what it switched off and dies. The next start must see the
|
||||
/// marker (and which targets), which is what makes it force the desk back on.
|
||||
#[test]
|
||||
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
|
||||
with_temp_dir("roundtrip", |_| {
|
||||
assert_eq!(pending(), None, "a clean box owes no recovery");
|
||||
mark(&[101, 202]);
|
||||
assert_eq!(
|
||||
pending(),
|
||||
Some(vec![101, 202]),
|
||||
"a crashed host's marker must be readable by the next start"
|
||||
);
|
||||
clear();
|
||||
assert_eq!(pending(), None, "a clean teardown retracts the marker");
|
||||
});
|
||||
}
|
||||
|
||||
/// An isolate that deactivated nothing (single-display box: the virtual output is already
|
||||
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
|
||||
/// desk we never touched.
|
||||
#[test]
|
||||
fn deactivating_nothing_writes_no_marker() {
|
||||
with_temp_dir("empty", |_| {
|
||||
mark(&[]);
|
||||
assert_eq!(pending(), None);
|
||||
});
|
||||
}
|
||||
|
||||
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
|
||||
/// that must not mean a disk write per cycle.
|
||||
#[test]
|
||||
fn repeating_the_same_mark_does_not_rewrite_the_file() {
|
||||
with_temp_dir("cached", |dir| {
|
||||
let file = dir.join("display-isolate-active.json");
|
||||
mark(&[7]);
|
||||
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
|
||||
// whose timestamp resolution is coarser than two back-to-back writes would let an
|
||||
// mtime assertion pass without proving anything.
|
||||
std::fs::write(&file, b"SENTINEL").unwrap();
|
||||
mark(&[7]);
|
||||
assert_eq!(
|
||||
std::fs::read(&file).unwrap(),
|
||||
b"SENTINEL",
|
||||
"an unchanged mark must not rewrite the journal"
|
||||
);
|
||||
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
|
||||
mark(&[7, 8]);
|
||||
assert_eq!(pending(), Some(vec![7, 8]));
|
||||
});
|
||||
}
|
||||
|
||||
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
|
||||
/// signal ("a host left displays off"), its contents are only diagnostics.
|
||||
#[test]
|
||||
fn an_unparseable_marker_still_asks_for_recovery() {
|
||||
with_temp_dir("corrupt", |dir| {
|
||||
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
|
||||
assert_eq!(pending(), Some(Vec::new()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
|
||||
return Some(saved);
|
||||
}
|
||||
|
||||
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
|
||||
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
|
||||
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
|
||||
// owes the operator back. See `isolate_journal`.
|
||||
let doomed: Vec<u32> = saved
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| p.targetInfo.id)
|
||||
.filter(|id| !keep_target_ids.contains(id))
|
||||
.collect();
|
||||
isolate_journal::mark(&doomed);
|
||||
|
||||
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
|
||||
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
|
||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
restore_displays_ccd_inner(saved);
|
||||
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
|
||||
// never before: a host that dies part-way through the restore must still leave the marker
|
||||
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
|
||||
// why this wraps rather than trailing the body.
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000;
|
||||
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
||||
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
||||
|
||||
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
|
||||
/// the host's own `RUMBLE_TTL_CEIL_MS`.
|
||||
///
|
||||
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
|
||||
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
|
||||
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
|
||||
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
|
||||
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
|
||||
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
|
||||
/// Android) already self-terminate at the clamped backstop.
|
||||
///
|
||||
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
|
||||
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
|
||||
/// header already has ~170 instances of, and one this has no reason to add to.
|
||||
const MAX_LEASE_MS: u16 = 5_000;
|
||||
|
||||
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
||||
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
||||
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
||||
@@ -75,8 +91,11 @@ struct PadState {
|
||||
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
||||
dirty: bool,
|
||||
next_keepalive: Option<Instant>,
|
||||
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
||||
jitter: bool,
|
||||
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
|
||||
/// silent. It replaces a free-running jitter phase because one field answers all three live
|
||||
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
|
||||
/// redundant, and would the nudge synthesize the reserved stop.
|
||||
last_emit: (u16, u16),
|
||||
quirks: ActuatorQuirks,
|
||||
}
|
||||
|
||||
@@ -88,7 +107,7 @@ impl PadState {
|
||||
legacy_wire: None,
|
||||
dirty: false,
|
||||
next_keepalive: None,
|
||||
jitter: false,
|
||||
last_emit: (0, 0),
|
||||
quirks: ActuatorQuirks {
|
||||
keepalive_ms: 0,
|
||||
min_pulse_ms: 0,
|
||||
@@ -112,6 +131,7 @@ impl PadState {
|
||||
self.legacy_wire = None;
|
||||
self.next_keepalive = None;
|
||||
self.dirty = false;
|
||||
self.last_emit = (0, 0);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low: 0,
|
||||
@@ -119,6 +139,40 @@ impl PadState {
|
||||
backstop_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the command for the pad's current level, and record what we handed out.
|
||||
///
|
||||
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
|
||||
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
|
||||
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
|
||||
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
|
||||
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
|
||||
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
|
||||
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
|
||||
/// floor, on an actuator whose quirk declares 40.
|
||||
///
|
||||
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
|
||||
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
|
||||
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
|
||||
/// and the pad never receives a stop the policy did not order.
|
||||
fn emit(&mut self, pad: u16) -> RumbleCommand {
|
||||
let (mut low, high) = self.level;
|
||||
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
|
||||
let alt = low ^ 1;
|
||||
low = if (alt, high) == (0, 0) {
|
||||
low | 0b10
|
||||
} else {
|
||||
alt
|
||||
};
|
||||
}
|
||||
self.last_emit = (low, high);
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: self.backstop(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
||||
@@ -156,6 +210,8 @@ impl RumbleEngine {
|
||||
p.dirty = true;
|
||||
match ttl_ms {
|
||||
Some(t) => {
|
||||
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
|
||||
let t = t.min(MAX_LEASE_MS);
|
||||
p.ttl_ms = t;
|
||||
p.legacy_wire = None;
|
||||
p.deadline = if (low, high) != (0, 0) {
|
||||
@@ -214,22 +270,25 @@ impl RumbleEngine {
|
||||
if p.dirty {
|
||||
p.dirty = false;
|
||||
if p.level == (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
// Relay a stop only if the actuator is, as far as the engine knows, still
|
||||
// buzzing. A zero on an already-silent pad heals nothing and costs every
|
||||
// embedder a command — Android an unconditional log line plus a binder
|
||||
// `cancel()`. Two senders produce them: the host's deliberate
|
||||
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
|
||||
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
|
||||
// zeros for every latched pad for the rest of the session. The burst still
|
||||
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
|
||||
// `last_emit != (0, 0)` and the re-send does emit.
|
||||
if p.last_emit != (0, 0) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if p.quirks.keepalive_ms > 0 {
|
||||
p.next_keepalive =
|
||||
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||
}
|
||||
let (low, high) = p.level;
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
||||
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
||||
@@ -239,20 +298,7 @@ impl RumbleEngine {
|
||||
let due = *p.next_keepalive.get_or_insert(now + ka);
|
||||
if now >= due {
|
||||
p.next_keepalive = Some(now + ka);
|
||||
let (mut low, high) = p.level;
|
||||
if p.quirks.dedup_jitter {
|
||||
p.jitter = !p.jitter;
|
||||
low ^= p.jitter as u16;
|
||||
}
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return (Some(p.emit(pad)), None);
|
||||
}
|
||||
merge_wake(&mut wake, due);
|
||||
}
|
||||
@@ -357,6 +403,22 @@ pub(crate) struct Closed;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
|
||||
const DECK: ActuatorQuirks = ActuatorQuirks {
|
||||
keepalive_ms: 40,
|
||||
min_pulse_ms: 0,
|
||||
dedup_jitter: true,
|
||||
};
|
||||
|
||||
/// Drain the engine the way an embedder does: poll until nothing is due.
|
||||
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
|
||||
let mut out = Vec::new();
|
||||
while let (Some(c), _) = e.poll(t) {
|
||||
out.push((c.low, c.high));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ms(v: u64) -> Duration {
|
||||
Duration::from_millis(v)
|
||||
}
|
||||
@@ -527,4 +589,133 @@ mod tests {
|
||||
);
|
||||
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
||||
}
|
||||
|
||||
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
|
||||
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
|
||||
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
|
||||
#[test]
|
||||
fn renewal_keeps_the_dedupe_jitter_alternating() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
|
||||
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
|
||||
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
|
||||
}
|
||||
|
||||
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
|
||||
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
|
||||
#[test]
|
||||
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
|
||||
for tick in 0..=360u64 {
|
||||
let t = t0 + ms(tick);
|
||||
if tick % 60 == 0 {
|
||||
e.wire_update(t, 0, 100, 200, Some(400));
|
||||
}
|
||||
for v in drain(&mut e, t) {
|
||||
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
|
||||
if v != last {
|
||||
worst = worst.max(tick - last_write);
|
||||
last_write = tick;
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst <= 41,
|
||||
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
|
||||
);
|
||||
}
|
||||
|
||||
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
|
||||
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
|
||||
#[test]
|
||||
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
|
||||
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
|
||||
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
|
||||
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
|
||||
}
|
||||
|
||||
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
|
||||
/// instead, so the phase still alternates and no stop is invented under a live lease.
|
||||
#[test]
|
||||
fn jitter_never_synthesizes_the_stop_sentinel() {
|
||||
let mut e = RumbleEngine::new();
|
||||
e.set_quirks(0, DECK);
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 1, 0, Some(400));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
|
||||
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
|
||||
}
|
||||
|
||||
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
|
||||
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
|
||||
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
|
||||
#[test]
|
||||
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
|
||||
// First stop reaches the embedder…
|
||||
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
|
||||
// …and the burst re-sends behind it are now silent.
|
||||
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
|
||||
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
|
||||
|
||||
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
|
||||
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
|
||||
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
|
||||
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
|
||||
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
|
||||
}
|
||||
|
||||
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
|
||||
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
|
||||
/// the Deck buzzing for the whole of it.
|
||||
#[test]
|
||||
fn an_overlong_lease_is_clamped_to_the_ceiling() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
|
||||
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
|
||||
// Silenced at the ceiling, not at the 65 s the sender asked for.
|
||||
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
|
||||
assert_eq!(
|
||||
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"the lease must end at the ceiling"
|
||||
);
|
||||
}
|
||||
|
||||
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
|
||||
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
|
||||
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
|
||||
/// Pinned so that ordering stays load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
|
||||
let mut e = RumbleEngine::new();
|
||||
let t0 = Instant::now();
|
||||
e.wire_update(t0, 0, 100, 200, Some(0));
|
||||
assert_eq!(
|
||||
e.poll(t0).0,
|
||||
Some(cmd(0, 0, 0, 0)),
|
||||
"a zero-length lease must expire immediately, not emit with a legacy backstop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
|
||||
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
|
||||
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
|
||||
//!
|
||||
//! **Not every fingerprint is a conflict.** Only a host that is running, or that will start on its
|
||||
//! own, can take the ports or load a second virtual-display driver. A leftover `Program Files`
|
||||
//! folder from an uninstall, a binary on `PATH`, or a service registered but *disabled* clashes
|
||||
//! with nothing — Sunshine's and Apollo's uninstallers both leave their config/log directories
|
||||
//! behind, so treating mere presence as a conflict cries wolf on a machine whose other host is long
|
||||
//! gone. [`Evidence::is_active`] draws that line and [`Detection::is_active`] lifts it to the
|
||||
//! product; the warning surfaces (startup log, `/local/summary` → the web console's conflicts card,
|
||||
//! the `detect-conflicts` exit code) report **only** active detections, while the full report still
|
||||
//! lists the dormant ones as context for support. This matches the installer's own probe
|
||||
//! (`punktfunk-host.iss`'s `StreamHostEnabled`: service start type <= 2), which was narrowed to
|
||||
//! exactly this rule after a dormant Sunshine aborted a `winget install` in the field, and the tray,
|
||||
//! which dropped its always-on warning over a merely-installed Sunshine in `3e782852`.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -73,17 +86,38 @@ impl Product {
|
||||
pub enum Evidence {
|
||||
/// A matching process is running **right now** (process/executable basename).
|
||||
Running { process: String },
|
||||
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
|
||||
Service { name: String },
|
||||
/// An OS service / systemd unit for the product is registered. `autostart` is the load-bearing
|
||||
/// bit: a service that comes up on its own (Windows start type boot/system/automatic; an enabled
|
||||
/// systemd unit) *will* clash, whereas a disabled/manual one is inert until someone starts it by
|
||||
/// hand — at which point the `Running` evidence catches it on the next scan.
|
||||
Service { name: String, autostart: bool },
|
||||
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
|
||||
/// Always dormant: files that nothing launches bind no ports.
|
||||
Installed { at: String },
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// Does this observation mean a conflicting host will actually take the ports / load a second
|
||||
/// virtual-display driver? See the module docs — this is the whole false-alarm fix.
|
||||
pub fn is_active(&self) -> bool {
|
||||
match self {
|
||||
Evidence::Running { .. } => true,
|
||||
Evidence::Service { autostart, .. } => *autostart,
|
||||
Evidence::Installed { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
match self {
|
||||
Evidence::Running { process } => format!("running now ({process})"),
|
||||
Evidence::Service { name } => format!("service {name}"),
|
||||
Evidence::Service {
|
||||
name,
|
||||
autostart: true,
|
||||
} => format!("service {name} (starts automatically)"),
|
||||
Evidence::Service {
|
||||
name,
|
||||
autostart: false,
|
||||
} => format!("service {name} (disabled/manual — dormant)"),
|
||||
Evidence::Installed { at } => format!("installed at {at}"),
|
||||
}
|
||||
}
|
||||
@@ -105,12 +139,24 @@ impl Detection {
|
||||
.any(|e| matches!(e, Evidence::Running { .. }))
|
||||
}
|
||||
|
||||
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
|
||||
/// True when this host is running **or** will start on its own — i.e. the detection is worth
|
||||
/// warning a user about. A product seen only as files on disk or a disabled service is dormant
|
||||
/// and reports `false`; see the module docs.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.evidence.iter().any(Evidence::is_active)
|
||||
}
|
||||
|
||||
/// A compact one-line label for the console summary, e.g. `Sunshine (running)`. The qualifier
|
||||
/// names what was actually observed, so a card built from these labels can never claim a
|
||||
/// dormant install is running.
|
||||
pub fn label(&self) -> String {
|
||||
let name = self.product.label();
|
||||
if self.is_running() {
|
||||
format!("{} (running)", self.product.label())
|
||||
format!("{name} (running)")
|
||||
} else if self.is_active() {
|
||||
format!("{name} (starts automatically)")
|
||||
} else {
|
||||
self.product.label().to_string()
|
||||
format!("{name} (installed, not running)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,28 +271,66 @@ pub fn snapshot() -> &'static [Detection] {
|
||||
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
|
||||
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
|
||||
detections.iter().map(Detection::label).collect()
|
||||
/// True if any detection is active — the one gate the warning surfaces share (startup log, the
|
||||
/// `detect-conflicts` exit code, the console card).
|
||||
pub fn any_active(detections: &[Detection]) -> bool {
|
||||
detections.iter().any(Detection::is_active)
|
||||
}
|
||||
|
||||
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
|
||||
/// Empty string when nothing was detected (callers gate on `is_empty()`).
|
||||
/// Compact labels for the web-console summary (e.g. `["Sunshine (running)"]`).
|
||||
///
|
||||
/// **Active detections only.** A dormant leftover (an uninstalled Sunshine's `Program Files` folder,
|
||||
/// a disabled service) is deliberately absent: this feeds the console's conflicts card, which exists
|
||||
/// to explain why clients cannot reach a working-looking host, and files that nothing launches never
|
||||
/// cause that. The full [`render_report`] still lists them for support.
|
||||
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
|
||||
detections
|
||||
.iter()
|
||||
.filter(|d| d.is_active())
|
||||
.map(Detection::label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A full human-readable report, split by whether the finding can actually clash. Empty string when
|
||||
/// nothing was detected at all (callers gate on `is_empty()`).
|
||||
///
|
||||
/// The dormant section is why this stays verbose where [`summary_labels`] is quiet: when a user asks
|
||||
/// "why does Punktfunk think I have Apollo?", the answer is the exact leftover path, and the report
|
||||
/// says in the same breath that it needs no action.
|
||||
pub fn render_report(detections: &[Detection]) -> String {
|
||||
if detections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = String::from("Detected another game-streaming host on this machine.\n");
|
||||
s.push_str(UNSUPPORTED_BLURB);
|
||||
s.push_str("\n\nDetected:\n");
|
||||
for d in detections {
|
||||
let bullet = |d: &Detection| {
|
||||
let ev = d
|
||||
.evidence
|
||||
.iter()
|
||||
.map(Evidence::render)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
|
||||
format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label())
|
||||
};
|
||||
let (active, dormant): (Vec<_>, Vec<_>) = detections.iter().partition(|d| d.is_active());
|
||||
let mut s = String::new();
|
||||
if !active.is_empty() {
|
||||
s.push_str("Detected another game-streaming host on this machine.\n");
|
||||
s.push_str(UNSUPPORTED_BLURB);
|
||||
s.push_str("\n\nDetected:\n");
|
||||
for d in &active {
|
||||
s.push_str(&bullet(d));
|
||||
}
|
||||
}
|
||||
if !dormant.is_empty() {
|
||||
if !active.is_empty() {
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str(
|
||||
"Also present but DORMANT — not running and not set to start on its own, so it clashes \
|
||||
with nothing and needs no action (typically leftovers from an uninstall):\n",
|
||||
);
|
||||
for d in &dormant {
|
||||
s.push_str(&bullet(d));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -275,15 +359,19 @@ mod tests {
|
||||
},
|
||||
Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert!(d.is_running());
|
||||
assert!(d.is_active());
|
||||
assert_eq!(d.label(), "Sunshine (running)");
|
||||
}
|
||||
|
||||
/// The field case this split exists for: Apollo uninstalled, its `Program Files` folder left
|
||||
/// behind. Nothing launches it, so it is NOT a conflict and must never reach the console card.
|
||||
#[test]
|
||||
fn installed_only_is_not_running() {
|
||||
fn a_leftover_install_dir_is_dormant_and_never_surfaces() {
|
||||
let d = det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
@@ -291,42 +379,77 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
assert!(!d.is_running());
|
||||
assert_eq!(d.label(), "Apollo");
|
||||
assert!(!d.is_active(), "files on disk cannot bind a port");
|
||||
assert_eq!(d.label(), "Apollo (installed, not running)");
|
||||
assert!(summary_labels(std::slice::from_ref(&d)).is_empty());
|
||||
assert!(!any_active(&[d]));
|
||||
}
|
||||
|
||||
/// A registered-but-DISABLED service is the other half of the same false alarm: `service_exists`
|
||||
/// used to count it, which disagreed with the installer's `Start <= 2` probe.
|
||||
#[test]
|
||||
fn a_disabled_service_is_dormant_but_an_autostart_one_is_not() {
|
||||
let disabled = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: false,
|
||||
}],
|
||||
);
|
||||
assert!(!disabled.is_active());
|
||||
assert!(summary_labels(&[disabled]).is_empty());
|
||||
|
||||
let auto = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Service {
|
||||
name: "SunshineService".into(),
|
||||
autostart: true,
|
||||
}],
|
||||
);
|
||||
assert!(auto.is_active());
|
||||
assert!(!auto.is_running(), "registered to start != started");
|
||||
assert_eq!(auto.label(), "Sunshine (starts automatically)");
|
||||
assert_eq!(
|
||||
summary_labels(&[auto]),
|
||||
vec!["Sunshine (starts automatically)".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_lists_every_product_and_the_blurb() {
|
||||
let report = render_report(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
}],
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "/usr/bin/apollo".into(),
|
||||
}],
|
||||
),
|
||||
]);
|
||||
fn report_separates_active_from_dormant_and_keeps_the_blurb() {
|
||||
let active = det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into(),
|
||||
}],
|
||||
);
|
||||
let dormant = det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed {
|
||||
at: "/usr/bin/apollo".into(),
|
||||
}],
|
||||
);
|
||||
let report = render_report(&[active.clone(), dormant.clone()]);
|
||||
assert!(report.contains("UNSUPPORTED"));
|
||||
// The bullets name the PRODUCT and let the evidence speak — `Detection::label`'s qualifier
|
||||
// would only restate what follows the dash ("Sunshine (running) — running now (sunshine)").
|
||||
// The qualifier is for `summary_labels`, which has no evidence text beside it.
|
||||
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
|
||||
assert!(report.contains("DORMANT"));
|
||||
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
|
||||
// Only the live one is offered to the console card.
|
||||
assert_eq!(
|
||||
summary_labels(&[
|
||||
det(
|
||||
Product::Sunshine,
|
||||
vec![Evidence::Running {
|
||||
process: "sunshine".into()
|
||||
}]
|
||||
),
|
||||
det(
|
||||
Product::Apollo,
|
||||
vec![Evidence::Installed { at: "x".into() }]
|
||||
),
|
||||
]),
|
||||
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
|
||||
summary_labels(&[active, dormant.clone()]),
|
||||
vec!["Sunshine (running)".to_string()]
|
||||
);
|
||||
|
||||
// A dormant-only machine gets the explanatory listing WITHOUT the "unsupported" alarm — the
|
||||
// whole point is that this needs no action.
|
||||
let dormant_only = render_report(&[dormant]);
|
||||
assert!(dormant_only.contains("DORMANT"));
|
||||
assert!(
|
||||
!dormant_only.contains("UNSUPPORTED"),
|
||||
"a leftover folder must not read as an unsupported dual-host setup:\n{dormant_only}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
for unit in known.linux_units {
|
||||
let file = format!("{unit}.service");
|
||||
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
|
||||
ev.push(Evidence::Service { name: file });
|
||||
let autostart = unit_enabled(&file, home.as_deref());
|
||||
ev.push(Evidence::Service {
|
||||
name: file,
|
||||
autostart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +82,49 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
ev
|
||||
}
|
||||
|
||||
/// Is `unit` (a `<name>.service` filename) **enabled** — i.e. will systemd start it on its own?
|
||||
///
|
||||
/// `systemctl enable` works by symlinking the unit into a target's `.wants`/`.requires` directory,
|
||||
/// so the presence of that link is the enablement fact — readable without spawning `systemctl`
|
||||
/// (this module is deliberately subprocess-free, and the host often runs where `systemctl` output
|
||||
/// would need a bus connection anyway). A unit file that exists but is linked from no target is
|
||||
/// installed-but-inert: nothing starts it at boot, so it clashes with nothing.
|
||||
///
|
||||
/// Scans the `.wants`/`.requires` subdirectories of the drop-in roots systemd actually reads, rather
|
||||
/// than hardcoding `multi-user.target` — a unit pulled in by `graphical.target`, a user
|
||||
/// `default.target`, or any other target is just as enabled.
|
||||
fn unit_enabled(unit: &str, home: Option<&std::ffi::OsStr>) -> bool {
|
||||
let mut roots: Vec<String> = vec![
|
||||
"/etc/systemd/system".into(),
|
||||
"/run/systemd/system".into(),
|
||||
"/usr/lib/systemd/system".into(),
|
||||
"/lib/systemd/system".into(),
|
||||
"/etc/systemd/user".into(),
|
||||
"/usr/lib/systemd/user".into(),
|
||||
];
|
||||
if let Some(h) = home {
|
||||
roots.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
|
||||
}
|
||||
for root in roots {
|
||||
let Ok(entries) = std::fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !(name.ends_with(".wants") || name.ends_with(".requires")) {
|
||||
continue;
|
||||
}
|
||||
// `symlink_metadata` so a DANGLING link still counts: a link into a target's .wants is
|
||||
// what "enabled" means, and a broken one still says the operator enabled it.
|
||||
if std::fs::symlink_metadata(entry.path().join(unit)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
|
||||
let dirs = path.map(std::env::split_paths).into_iter().flatten();
|
||||
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
|
||||
|
||||
@@ -7,7 +7,7 @@ use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_service::service::ServiceAccess;
|
||||
use windows_service::service::{ServiceAccess, ServiceStartType};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
|
||||
@@ -49,9 +49,10 @@ pub fn running_processes() -> Vec<String> {
|
||||
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
let mut ev = Vec::new();
|
||||
for svc in known.win_services {
|
||||
if service_exists(svc) {
|
||||
if let Some(autostart) = service_start_type(svc) {
|
||||
ev.push(Evidence::Service {
|
||||
name: (*svc).to_string(),
|
||||
autostart,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,14 +64,35 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
|
||||
ev
|
||||
}
|
||||
|
||||
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
|
||||
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
|
||||
fn service_exists(name: &str) -> bool {
|
||||
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
|
||||
/// `Some(autostart)` if a service by this name is registered with the SCM (running or stopped),
|
||||
/// `None` if it does not exist. Opening it fails cleanly when it doesn't exist.
|
||||
///
|
||||
/// `autostart` mirrors the installer's `StreamHostEnabled` (start type <= 2): only boot/system/auto
|
||||
/// come up on their own, and only a host that comes up can take the GameStream ports. A disabled or
|
||||
/// manual service is dormant — see the module docs on `super`. When the start type cannot be read
|
||||
/// (no `QUERY_CONFIG` right) we report the service as dormant rather than guessing it autostarts:
|
||||
/// the false-alarm this whole split exists to kill is worse than a missed warning, and a host that
|
||||
/// is genuinely up is caught by the process scan regardless of what its service config says.
|
||||
fn service_start_type(name: &str) -> Option<bool> {
|
||||
let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
|
||||
let svc = mgr
|
||||
.open_service(
|
||||
name,
|
||||
ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
|
||||
)
|
||||
// Fall back to a status-only handle so a service we may not configure still registers as
|
||||
// present (dormant) instead of vanishing from the report entirely.
|
||||
.or_else(|_| mgr.open_service(name, ServiceAccess::QUERY_STATUS))
|
||||
.ok()?;
|
||||
let autostart = svc.query_config().is_ok_and(|c| {
|
||||
matches!(
|
||||
c.start_type,
|
||||
ServiceStartType::AutoStart
|
||||
| ServiceStartType::BootStart
|
||||
| ServiceStartType::SystemStart
|
||||
)
|
||||
});
|
||||
Some(autostart)
|
||||
}
|
||||
|
||||
/// The install directory under any of the Program Files roots, if it exists.
|
||||
|
||||
@@ -334,15 +334,26 @@ pub fn serve(
|
||||
"punktfunk host"
|
||||
);
|
||||
// Surface a conflicting Moonlight-compatible host (Sunshine/Apollo/…) as early as possible:
|
||||
// scan once (cached for `/local/summary` → tray + web console) and warn loudly if found.
|
||||
// scan once (cached for `/local/summary` → the web console) and warn loudly if one can actually
|
||||
// clash. A dormant leftover (an uninstalled Sunshine's Program Files folder, a disabled service)
|
||||
// is logged at INFO instead — it belongs in a support log, not in a warning that reads like a
|
||||
// fault on every boot.
|
||||
let conflicts = crate::detect::init();
|
||||
if !conflicts.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "punktfunk::detect",
|
||||
count = conflicts.len(),
|
||||
"{}",
|
||||
crate::detect::render_report(conflicts)
|
||||
);
|
||||
let report = crate::detect::render_report(conflicts);
|
||||
if crate::detect::any_active(conflicts) {
|
||||
tracing::warn!(
|
||||
target: "punktfunk::detect",
|
||||
count = conflicts.len(),
|
||||
"{report}"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "punktfunk::detect",
|
||||
count = conflicts.len(),
|
||||
"{report}"
|
||||
);
|
||||
}
|
||||
}
|
||||
if gamestream {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -104,10 +104,14 @@ mod tray;
|
||||
mod store;
|
||||
mod stream_marker;
|
||||
mod update;
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
// The two startup crash-recovery legs (below), both in the `pf-win-display` leaf crate (plan §W6):
|
||||
// `monitor_devnode::startup_recover()` re-enables PnP monitor devnodes disabled by a prior run, and
|
||||
// `isolate_journal::startup_recover()` re-lights displays a prior run deactivated for an EXCLUSIVE
|
||||
// session and never restored.
|
||||
#[cfg(target_os = "windows")]
|
||||
use pf_win_display::monitor_devnode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use pf_win_display::win_display::isolate_journal;
|
||||
// Virtual-display orchestration lives in the `pf-vdisplay` subsystem crate (plan §W6); this shim
|
||||
// keeps every existing `crate::vdisplay::*` path valid (serve/mgmt/native/capture consume the trait,
|
||||
// registry, and manager through it). The DDC panel control + the KWin zkde protocol moved with it.
|
||||
@@ -379,6 +383,12 @@ fn real_main() -> Result<()> {
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
#[cfg(target_os = "windows")]
|
||||
monitor_devnode::startup_recover();
|
||||
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
|
||||
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
|
||||
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
|
||||
// monitors are present again and the EXTEND preset can actually light them.
|
||||
#[cfg(target_os = "windows")]
|
||||
isolate_journal::startup_recover();
|
||||
gamestream::serve(mgmt_opts, native, gamestream)
|
||||
}
|
||||
// Report other Moonlight-compatible hosts (Sunshine/Apollo/…) installed or running on this
|
||||
@@ -388,11 +398,17 @@ fn real_main() -> Result<()> {
|
||||
let found = detect::scan();
|
||||
if found.is_empty() {
|
||||
println!("No conflicting game-streaming host detected.");
|
||||
Ok(())
|
||||
} else {
|
||||
print!("{}", detect::render_report(&found));
|
||||
return Ok(());
|
||||
}
|
||||
print!("{}", detect::render_report(&found));
|
||||
// Exit 1 ONLY for a host that runs or will start on its own. The installers and support
|
||||
// scripts gate on this code, and a dormant leftover used to abort them — a `winget
|
||||
// install` failed in the field on a box whose Sunshine was merely present (see the
|
||||
// module docs + `punktfunk-host.iss`). Dormant findings print, then exit 0.
|
||||
if detect::any_active(&found) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
|
||||
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
|
||||
|
||||
@@ -194,6 +194,21 @@ which forwards *every* connected controller, each as its own player, on Linux, W
|
||||
console home. Pinning one restricts the session to that controller alone — single-player. The Android
|
||||
app has no such picker.
|
||||
|
||||
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every
|
||||
client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to
|
||||
host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except
|
||||
Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw
|
||||
there opens *both* menus at once, the local one covering the stream. The full story, including how
|
||||
to reach the host's menus when the raw press stays local, is on the
|
||||
[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
|
||||
|
||||
**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the
|
||||
host's guide button from any controller: hold Select (Back/View) on its own for about a third of a
|
||||
second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a
|
||||
regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly —
|
||||
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
|
||||
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
|
||||
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
|
||||
@@ -99,6 +99,39 @@ there the client stops opening the controller at all, which is the point of the
|
||||
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
|
||||
watching for the chord either way.
|
||||
|
||||
### The guide button (Xbox / PS / Steam) and Quick Access
|
||||
|
||||
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
||||
meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some
|
||||
devices want that button for themselves, so every client also carries a gesture that works
|
||||
everywhere:
|
||||
|
||||
**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide
|
||||
button go down, and it stays down for as long as you hold — so keeping it held reads as a long
|
||||
press on the host, which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick
|
||||
tap of Select still reaches the game, delivered when you let go (a beat late). Select pressed as
|
||||
part of a combo — including the leave chord above — passes through untouched.
|
||||
|
||||
What the raw button does, per client:
|
||||
|
||||
- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If
|
||||
Steam Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*,
|
||||
both may react — that's a local setting on that device, not something the stream can suppress.
|
||||
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default:
|
||||
SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH
|
||||
menus at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or
|
||||
with the Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The
|
||||
old behavior is one setting away: **Steam / guide button → Send to host**.
|
||||
- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the
|
||||
reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the
|
||||
app yourself, in the system's per-controller Home-button setting.
|
||||
- **Apple TV** — tvOS never delivers the Home press to apps; hold-Select is the only route.
|
||||
|
||||
Both halves are [settings](/docs/client-settings#input), per profile like everything else:
|
||||
**Steam / guide button** (Automatic / Send to host / This device) and **Hold Select for guide**
|
||||
(Automatic / On / Off). Automatic picks the behavior above for each platform — the gesture stays
|
||||
off where the raw button already works, so games that use a *held* Select keep it.
|
||||
|
||||
## Mouse modes
|
||||
|
||||
There are two, and they are a per-client setting called **Mouse input**:
|
||||
|
||||
@@ -143,6 +143,15 @@ for about a second and a half, or close the "game" from the Steam overlay. Eithe
|
||||
and drops you straight back to Gaming Mode. A quick press of the same four only releases captured
|
||||
input, so it is safe to hit by accident.
|
||||
|
||||
**The Steam and `…` buttons stay with the Deck while streaming.** SteamOS opens its own menus for
|
||||
them no matter what, so forwarding the raw press as well opened *both* menus at once — the Deck's
|
||||
covering the stream. To reach the **host's** menus instead: **hold Select** for the host's Steam
|
||||
menu ([how it works](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access)), or open the
|
||||
Punktfunk panel — while a stream runs it grows a **Host menus** section whose two buttons,
|
||||
**Steam menu on host** and **Quick access on host**, press the button on the host and close the
|
||||
Deck's own menu so the host's shows through. Want the raw forwarding back? **Open Punktfunk →
|
||||
Settings → Steam / guide button** → *Send to host*.
|
||||
|
||||
## Updating
|
||||
|
||||
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
|
||||
|
||||
@@ -360,9 +360,32 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 {
|
||||
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
|
||||
/// confirmed stuck-rumble path).
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
// Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it
|
||||
// names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two
|
||||
// can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the
|
||||
// SAME slot — tearing one report's bytes across the other's — and both store head+1, so the
|
||||
// cursor advances once for two reports and the host sees a single torn entry.
|
||||
//
|
||||
// An atomic `fetch_add` on the head does not fix it. That hands each writer a distinct slot,
|
||||
// but it advances the cursor BEFORE the slot bytes exist, so the host can read a slot that is
|
||||
// still being filled — trading a torn slot for a torn slot the host is invited to read. Making
|
||||
// the head-advance mean "the slot below is complete" is exactly what the lock buys.
|
||||
//
|
||||
// Poison-tolerant on purpose. Poison is sticky, so the repo's usual `if let Ok(g) = lock()`
|
||||
// would skip the publish for the REST OF THE PROCESS after a single panic elsewhere — silently
|
||||
// ending game output. Recovering the guard is safe here: the protected state is bytes in a
|
||||
// shared section, not an invariant a panic could have broken.
|
||||
let _publish = RING_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
view.write_bytes(OFF_OUTPUT, bytes);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
// Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its
|
||||
// copy of the report bytes after it (`dualsense_windows.rs`, "Acquire pairs with the driver's
|
||||
// publish-then-bump store order"). An Acquire load pairs with a Release store and nothing
|
||||
// else, so as a plain write this promised the host an ordering it never actually established —
|
||||
// on a weakly-ordered core (ARM64) the fresh seq could arrive ahead of the bytes it announces.
|
||||
view.store_u32(OFF_OUT_SEQ, seq, Ordering::Release);
|
||||
let len = ring_len(view);
|
||||
if len != 0 {
|
||||
let head = view.read_u32(OFF_RING_HEAD);
|
||||
@@ -375,6 +398,11 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes [`publish_output`] against itself — see the note there for why an atomic cursor is
|
||||
/// not enough. Uncontended in the common case: one output report at a time is the norm, and the
|
||||
/// critical section is a few dozen bytes of memcpy into an already-mapped view.
|
||||
static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||
|
||||
@@ -358,20 +358,48 @@ fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i1
|
||||
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
||||
/// the game-visible polling path advance.
|
||||
fn touch_driver_marks(data: &MappedView) {
|
||||
let _marks = SECTION_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||
}
|
||||
|
||||
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
||||
///
|
||||
/// Serialized and Release-published, because IOCTLs arrive concurrently and neither property held
|
||||
/// before. `seq` was a read-modify-write across the two motor bytes: two `SET_STATE` calls could
|
||||
/// both read the same value and both write back `seq + 1`, so the host — which treats an unchanged
|
||||
/// seq as "nothing new" — saw one bump for two writes and skipped a level entirely. A skipped
|
||||
/// **stop** is the one that hurts: the pad keeps buzzing until the host's ~2.5 s idle force-off
|
||||
/// notices the game went quiet, which is where the bound on this bug comes from.
|
||||
///
|
||||
/// The seq store is Release for the same reason as `pf-gamepad`'s `out_seq`: the host loads it with
|
||||
/// Acquire and documents that as ordering its read of the motor bytes ("the driver bumps
|
||||
/// `rumble_seq` AFTER writing the rumble bytes", `gamepad_windows.rs`). A plain write gives that
|
||||
/// Acquire nothing to pair with, so the guarantee the host's comment claims did not exist in either
|
||||
/// direction — the host could read a fresh seq against stale motor levels on a weakly-ordered core.
|
||||
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
||||
let Some(v) = data else { return };
|
||||
let _publish = SECTION_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
v.write_u8(OFF_RUMBLE_LARGE, large);
|
||||
v.write_u8(OFF_RUMBLE_SMALL, small);
|
||||
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
||||
v.write_u32(OFF_RUMBLE_SEQ, seq);
|
||||
v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Serializes the section's read-modify-write publishes ([`publish_rumble`], [`touch_driver_marks`])
|
||||
/// against each other. One lock rather than one per field: they are all short byte writes into the
|
||||
/// same mapped view, and the contention is nil compared to the IOCTL round trip that reaches them.
|
||||
///
|
||||
/// Poison-tolerant deliberately — poison is sticky, so bailing out on it would silently stop
|
||||
/// forwarding rumble for the rest of the process. The protected state is bytes in a shared section,
|
||||
/// not an invariant a panic elsewhere could have violated.
|
||||
static SECTION_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
||||
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
||||
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
|
||||
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
|
||||
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
|
||||
"host_conflicts_title": "Auf diesem Rechner ist ein weiterer Game-Streaming-Server aktiv",
|
||||
"host_conflicts_help": "Er läuft oder startet automatisch mit und belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende und deaktiviere den anderen Server und starte Punktfunk neu. Ein Server, der nur installiert ist, stört nicht und wird hier nicht aufgeführt.",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_config_title": "Konfiguration",
|
||||
"display_preset": "Voreinstellung",
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
|
||||
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
|
||||
"host_conflicts_title": "Another game-streaming server is running on this machine",
|
||||
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
|
||||
"host_conflicts_title": "Another game-streaming server is active on this machine",
|
||||
"host_conflicts_help": "It is running, or set to start on its own, and listens on the same ports as Punktfunk — so whichever one started first answers your clients, which is usually why a working-looking host cannot be connected to. Stop and disable the other server, then restart Punktfunk. A server that is only left installed does not clash and is not listed here.",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_config_title": "Configuration",
|
||||
"display_preset": "Preset",
|
||||
|
||||
@@ -7,11 +7,17 @@ import { m } from "@/paraglide/messages";
|
||||
/**
|
||||
* "Something else is already listening on these ports."
|
||||
*
|
||||
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) running on the same
|
||||
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
|
||||
* even though it is the single most common reason a punktfunk host looks installed and working but
|
||||
* no client can reach it — two servers fighting over the same ports, with whichever won the bind
|
||||
* answering the client.
|
||||
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) on the same machine at
|
||||
* startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, even though
|
||||
* it is the single most common reason a Punktfunk host looks installed and working but no client can
|
||||
* reach it — two servers fighting over the same ports, with whichever won the bind answering the
|
||||
* client.
|
||||
*
|
||||
* `conflicts` carries only servers that are running or set to start on their own; the host filters
|
||||
* dormant leftovers out (see `detect.rs`), because an uninstalled Sunshine's `Program Files` folder
|
||||
* clashes with nothing and this card used to shout about it on every load. Each entry names what was
|
||||
* observed — `Sunshine (running)`, `Apollo (starts automatically)` — so the heading never has to
|
||||
* guess, which it previously did by hardcoding "is running".
|
||||
*
|
||||
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user