From 9fb41affba5fc188e25de804832ad3d45396a32f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 22:25:51 +0200 Subject: [PATCH] fix(clients/settings): a controller setting you can't use no longer looks like one you can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised them, the touch settings on both mobile clients dimmed them and the console UI refused the step; the Windows client and BOTH controller-navigable screens left them fully live, so you could sit there changing settings that did nothing. Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type, guide button and hold-Select rows — the same builder the echo-cancellation row already used to follow the mic switch. Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)` / `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change" on a dimmed row, because advertising them was the same lie the live row told. Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" — but it only faded the label: every dimmed row still stepped and still wrote its setting. The "No profiles yet" placeholder looked inert only because its own closures were empty. Made it real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap on the already-focused row), then gated the pad rows on it. Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings have carried beside its SC2 twin all along. It was missing exactly where it matters most — a TV box has no touch interface to fall back to, so there was no way to reach it at all. Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's system gesture and powered the controller's IMU. Neither reaches the host, so the first only took the user's screenshot/Home gestures away for nothing and the second drained the pad's battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's own four buttons keep their claim. A test pins the alias list against the chord mask; if they drift the symptom is a session nobody can leave, with nothing logged. Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and "Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen was not — both corrected here. Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean + full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt --all --check clean. Each fix probed by reverting it — every probe failed the tests it should. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 54 ++++++++--- .../unom/punktfunk/GamepadSettingsRowsTest.kt | 97 +++++++++++++++++++ .../Settings/GamepadSettingsView.swift | 50 ++++++++-- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 29 +++++- .../GamepadEscapeChordTests.swift | 52 ++++++++++ clients/windows/src/app/settings.rs | 16 ++- 6 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index c33e4f88..b085163c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, // B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. -private class GpRow( +internal class GpRow( val id: String, val header: String?, val label: String, @@ -78,6 +78,15 @@ private class GpRow( val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) +/** + * The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of + * [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the + * already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing + * else, and every dimmed row still stepped its setting. + */ +internal fun liveRow(rows: List, index: Int): GpRow? = + rows.getOrNull(index)?.takeIf { it.enabled } + @Composable fun GamepadSettingsScreen( initial: Settings, @@ -144,11 +153,13 @@ fun GamepadSettingsScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) } + // A disabled row is INERT, not just dim — the step is refused instead of writing a + // setting that has nothing to act on (see `liveRow`). + NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } + NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } } }, - onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() }, + onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. @@ -186,7 +197,10 @@ fun GamepadSettingsScreen( } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - if (focus == index) { adjustDir = 1; row.activate() } else focus = index + // Same inertness as the pad path above — tapping a dimmed row focuses it (so + // its detail explains itself) but never flips it. + if (focus != index) focus = index + else if (row.enabled) { adjustDir = 1; row.activate() } }) } } @@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the * AV1 codec entry (see `codecOptionsFor`). */ -private fun buildSettingsRows( +internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, av1Capable: Boolean, @@ -348,13 +362,14 @@ private fun buildSettingsRows( ): List { fun choice( id: String, header: String?, label: String, detail: String, - options: List>, current: T, write: (T) -> Unit, + options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( id, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, + enabled = enabled, adjust = { delta -> if (idx < 0) { options.firstOrNull()?.let { write(it.first) } != null @@ -371,11 +386,12 @@ private fun buildSettingsRows( } fun toggle( id: String, header: String?, label: String, detail: String, - value: Boolean, write: (Boolean) -> Unit, + value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( id, header, label, value = if (value) "On" else "Off", detail = detail, + enabled = enabled, adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false }, activate = { write(!value) }, toggled = value, @@ -478,22 +494,26 @@ private fun buildSettingsRows( "so games don't see two of them.", s.gamepadForwarding, ) { update(s.copy(gamepadForwarding = it)) }, + // Everything below the master switch follows it — dim and inert while nothing is being + // forwarded, the same relationship the touch settings draw with `enabled =`. This screen + // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so + // the pad rows kept stepping settings that had nothing to act on. choice( "padType", null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", - GAMEPAD_OPTIONS, s.gamepad, + GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { 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, + SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { 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, + GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, ) { update(s.copy(guideGesture = it)) }, ) + listOfNotNull( if (hasBodyVibrator) { @@ -513,8 +533,18 @@ private fun buildSettingsRows( "sc2", null, "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", - s.sc2Capture, + s.sc2Capture, enabled = s.gamepadForwarding, ) { update(s.copy(sc2Capture = it)) }, + // The SC2 row's twin, and missing here until now: the touch settings have carried both + // side by side, so a couch user on a TV box — where there IS no touch interface to fall + // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate + // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. + toggle( + "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + + "triggers, lightbar and gyro.", + s.dsCapture, enabled = s.gamepadForwarding, + ) { update(s.copy(dsCapture = it)) }, ) } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt new file mode 100644 index 00000000..b9012c0f --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt @@ -0,0 +1,97 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The controller-navigable settings rows: what the master forwarding switch governs, and that a + * governed row is inert rather than merely dim. + * + * The touch settings and the desktop console have carried this relationship for a while (`enabled = + * s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so + * these tests pin both halves — the flag AND the refusal to write. + */ +class GamepadSettingsRowsTest { + + /** Rows for a given forwarding state, capturing whatever a row writes back. */ + private fun rows( + forwarding: Boolean, + sink: MutableList = mutableListOf(), + ): List = buildSettingsRows( + Settings(gamepadForwarding = forwarding), + hasBodyVibrator = true, + av1Capable = true, + ) { sink += it } + + private fun row(rows: List, id: String): GpRow = + rows.first { it.id == id } + + /** Every row that only means something while a controller is actually being forwarded. */ + private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture") + + @Test + fun `forwarding off dims every row that depends on it`() { + val off = rows(forwarding = false) + for (id in governed) { + assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled) + } + // The master switch itself stays live — otherwise it could never be turned back on. + assertTrue(row(off, "padForward").enabled) + } + + @Test + fun `forwarding on leaves them all live`() { + val on = rows(forwarding = true) + for (id in governed) { + assertTrue("$id should be live with forwarding on", row(on, id).enabled) + } + } + + @Test + fun `a dimmed row is inert - liveRow withholds it and nothing is written`() { + val writes = mutableListOf() + val off = rows(forwarding = false, sink = writes) + for (id in governed) { + val i = off.indexOfFirst { it.id == id } + assertNull("$id must not be reachable while dimmed", liveRow(off, i)) + // What the screen actually does on left/right/A — the whole point is that it no-ops. + liveRow(off, i)?.adjust(1) + liveRow(off, i)?.adjust(-1) + liveRow(off, i)?.activate() + } + assertEquals("a dimmed row wrote a setting", emptyList(), writes) + } + + @Test + fun `the same rows do write once forwarding is on`() { + val writes = mutableListOf() + val on = rows(forwarding = true, sink = writes) + val i = on.indexOfFirst { it.id == "sc2" } + assertNotNull(liveRow(on, i)) + liveRow(on, i)?.activate() + assertEquals(1, writes.size) + assertFalse("activate flips the toggle", writes[0].sc2Capture) + } + + /** + * R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly + * where this screen is the only one reachable — a TV box has no touch interface to fall back to. + */ + @Test + fun `the DualSense passthrough toggle is present, next to its SC2 twin`() { + val on = rows(forwarding = true) + val ids = on.map { it.id } + assertTrue("dsCapture row is missing", "dsCapture" in ids) + assertEquals( + "the two passthrough rows belong side by side", + ids.indexOf("sc2") + 1, + ids.indexOf("dsCapture"), + ) + // Drawn as a switch, and reading the persisted default. + assertEquals(true, row(on, "dsCapture").toggled) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 514e7bc8..09274f1f 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -166,6 +166,11 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // A dimmed row takes neither, so offering them would be the same lie the row itself + // used to tell — only Done remains, and the detail line says what to turn on first. + guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { + return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + } return [ .init(glyph: "arrow.left.and.right", text: "Adjust"), .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), @@ -218,7 +223,8 @@ struct GamepadSettingsView: View { HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -239,9 +245,13 @@ struct GamepadSettingsView: View { .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } + // Contents only — the glass and border below stay at full strength, so a dimmed row + // still reads as a row you can sit on (which you can: its detail is the point). + .opacity(row.enabled ? 1 : 0.45) .padding(.horizontal, m.rowHPad) .padding(.vertical, m.rowVPad) // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. @@ -276,6 +286,13 @@ struct GamepadSettingsView: View { /// Whether left/right means anything here — false hides the value's chevrons (the /// Profiles rows navigate, and the placeholder rows do nothing at all). var adjustable = true + /// Dimmed and inert when false: a row whose meaning depends on another setting that is + /// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the + /// user learns which switch to flip first, and a row that vanished mid-list would + /// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` / + /// `activate(id:)`, not per closure, so no row builder can forget it. + /// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.) + var enabled = true /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. @@ -286,12 +303,14 @@ struct GamepadSettingsView: View { /// (never on state captured at wire time). private func adjust(id: String, by delta: Int) -> Bool { lastAdjustDelta = delta - return rows.first { $0.id == id }?.adjust(delta) ?? false + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false } + return row.adjust(delta) } private func activate(id: String) { lastAdjustDelta = 1 // A always cycles forward - rows.first { $0.id == id }?.activate() + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return } + row.activate() } private var rows: [Row] { @@ -391,27 +410,35 @@ struct GamepadSettingsView: View { + "controller already reaches the host another way — USB passthrough such " + "as VirtualHere — so games don't see two of them.", value: $gamepadForwarding), + // The four rows below only mean something while something is being forwarded, so + // they follow the switch above — the same relationship the touch settings draw with + // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until + // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( id: "pad", icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", - options: controllers, current: gamepads.preferredID + options: controllers, current: gamepads.preferredID, + enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( id: "padType", icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", - options: SettingsOptions.padTypes, current: gamepadType + options: SettingsOptions.padTypes, current: gamepadType, + enabled: gamepadForwarding ) { 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 + options: SettingsOptions.systemButtons, current: systemButtons, + enabled: gamepadForwarding ) { 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 + options: SettingsOptions.guideGestures, current: guideGesture, + enabled: gamepadForwarding ) { guideGesture = $0 }, choiceRow( @@ -583,13 +610,15 @@ struct GamepadSettingsView: View { private func choiceRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void + options: [(label: String, tag: T)], current: T, enabled: Bool = true, + write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( id: id, header: header, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, + enabled: enabled, adjust: { delta in // Unknown current value: snap to the first option on any step. guard let index else { @@ -610,12 +639,13 @@ struct GamepadSettingsView: View { private func toggleRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - value: Binding + value: Binding, enabled: Bool = true ) -> Row { Row( id: id, header: header, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary. let target = delta > 0 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index b623f050..4ea7812b 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -98,8 +98,17 @@ public final class GamepadCapture { /// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is /// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release /// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD. - private static let escapeChord: UInt32 = + /// Internal rather than private only so `GamepadEscapeChordTests` can pin it against + /// `escapeChordElements` below — the two must not drift. + static let escapeChord: UInt32 = GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back + /// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed + /// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and + /// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two + /// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV. + static let escapeChordElements = [ + GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions, + ] /// 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 @@ -236,7 +245,17 @@ public final class GamepadCapture { // gesture attached the press is the system's, not the game's. During capture the remote // session IS the game: the share button must reach the host (e.g. Steam screenshots), // the PS button must open the host's Steam overlay. Restored to .enabled on close. - for element in c.physicalInputProfile.elements.values { + // + // With forwarding OFF none of that applies — no press reaches the host, so taking the + // user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord + // is still read off this slot, and on tvOS it is the only controller way out of a + // stream, so the chord's own four elements keep their claim. (Menu especially: leave + // its gesture attached on tvOS and the press is the system's — the chord would never + // complete and the session would have no controller exit at all.) + let claimed = forwarding + ? Array(c.physicalInputProfile.elements.values) + : Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] } + for element in claimed { element.preferredSystemGestureState = .disabled } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit, @@ -276,7 +295,11 @@ public final class GamepadCapture { MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } } } } - if let motion = c.motion { + // Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no + // local feature reads it. Powering the IMU anyway costs the pad real battery (it streams + // gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power + // it back down), so with nothing to forward we simply never turn it on. + if forwarding, let motion = c.motion { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } motion.valueChangedHandler = { [weak self, weak slot] m in MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift new file mode 100644 index 00000000..0531f7e7 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift @@ -0,0 +1,52 @@ +import GameController +import XCTest + +@testable import PunktfunkKit + +/// The escape chord's mask and its GameController alias list have to describe the same four +/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding +/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops +/// covering the mask, the missing button's press stays the system's and the chord never completes. +/// +/// That matters most on tvOS, where this chord is the only controller way out of a stream: the +/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes. +/// Hence a test on the invariant rather than trusting the comment beside it. +@MainActor +final class GamepadEscapeChordTests: XCTestCase { + + /// The intended alias↔bit pairing, spelled out independently of the implementation. + private let pairing: [(alias: String, bit: UInt32)] = [ + (GCInputLeftShoulder, GamepadWire.leftShoulder), + (GCInputRightShoulder, GamepadWire.rightShoulder), + (GCInputButtonMenu, GamepadWire.start), + (GCInputButtonOptions, GamepadWire.back), + ] + + func testChordMaskIsExactlyTheFourPairedButtons() { + XCTAssertEqual( + pairing.reduce(UInt32(0)) { $0 | $1.bit }, + GamepadCapture.escapeChord, + "the chord mask and the alias pairing describe different buttons") + } + + func testEveryChordBitHasAnElementToClaim() { + // One alias per bit — a mask that grew a fifth button without a matching alias would + // leave that button's gesture with the OS while forwarding is off. + XCTAssertEqual( + GamepadCapture.escapeChordElements.count, + GamepadCapture.escapeChord.nonzeroBitCount, + "alias list and chord mask differ in size") + XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias)) + } + + /// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of + /// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which + /// is the behaviour this deliberately avoids). + func testClaimListIsNonEmptyAndAllDistinct() { + XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty) + XCTAssertEqual( + Set(GamepadCapture.escapeChordElements).count, + GamepadCapture.escapeChordElements.count, + "a repeated alias would mean a chord bit has no element") + } +} diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 4bbdd924..ecfa1792 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -981,6 +981,13 @@ pub(crate) fn settings_page( s.forward_pad = key.unwrap_or_default(); s.save(); }) + // Dimmed with the master switch above it, like echo cancellation under the mic + // (see that row) — this and the three below have nothing to act on while no + // controller is forwarded at all. Every commit bumps `rev` and re-renders this + // screen, so they follow the toggle live. Brings this client in line with how GTK + // (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the + // console UI (dim + refuse the step) have always drawn the same relationship. + .enabled(s.gamepad_forwarding) }; let pad_forward_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| { @@ -991,7 +998,8 @@ 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(); - }); + }) + .enabled(s.gamepad_forwarding); let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons); let sysbtn_combo = setting_combo( ctx, @@ -1002,7 +1010,8 @@ pub(crate) fn settings_page( |s, i| { s.system_buttons = SYSTEM_BUTTONS[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture); let gesture_combo = setting_combo( ctx, @@ -1013,7 +1022,8 @@ pub(crate) fn settings_page( |s, i| { s.guide_gesture = GUIDE_GESTURES[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); 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();