From f3646d4e7c1499fa38390860a76f4a3e26f9f513 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 23:36:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple/gamepad):=20claim=20controller=20sys?= =?UTF-8?q?tem=20gestures=20during=20capture=20=E2=80=94=20PS=20button=20o?= =?UTF-8?q?pens=20the=20Steam=20overlay,=20share/create=20stops=20screensh?= =?UTF-8?q?otting=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a pad drives a stream, GamepadCapture now sets EVERY element's preferredSystemGestureState to .disabled (restored to .enabled on unbind). iOS/macOS attach system gestures to several controller buttons — share/create took a LOCAL screenshot instead of reaching the game, and only the Home element was opted out before. With the gestures claimed, the already-wired chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad (the Steam-overlay button) / the PS bit on the virtual DualSense. Also fold the share/create/capture element (GCInputButtonShare) into the back/select wire bit — clone pads like the GameSir G8 expose their screenshot button only as the share element, not buttonOptions (OR onto the same bit, so double-exposed pads are harmless). The G8's other extra button (M) is a firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 ++- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 74029ff..215fbe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc controller discovery + selection in Settings (`GamepadManager` — exactly one pad forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical controller, user-overridable), capture incl. DualSense touchpad/motion - (`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar / + (`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's + `preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as + select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` = + the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar / player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/ `GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser). Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index dbc0273..48fed91 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -102,6 +102,13 @@ public final class GamepadCapture { tp?.primary.valueChangedHandler = nil tp?.secondary.valueChangedHandler = nil } + // Hand the system gestures back to the OS before letting the old pad go — outside a + // stream the share button's screenshot and the Home overlay are the user's, not ours. + if let old = bound { + for element in old.physicalInputProfile.elements.values { + element.preferredSystemGestureState = .enabled + } + } if let motion = bound?.motion { motion.valueChangedHandler = nil // Power the sensors back down — left active they keep the pad streaming @@ -114,14 +121,21 @@ public final class GamepadCapture { ext.valueChangedHandler = { [weak self] g, _ in MainActor.assumeIsolated { self?.sync(g) } } - // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit). On - // macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached - // the app — `preferredSystemGestureState = .disabled` on the element is what hands it to us. - // We drive `guide` 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. + // Claim EVERY element's system gesture while this pad drives a stream. The OS attaches + // gestures to several controller buttons — share/create → local screenshot/recording, + // Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a + // 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 unbind. + for element in c.physicalInputProfile.elements.values { + 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 + // 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. if let home = c.physicalInputProfile.buttons[GCInputButtonHome] { - home.preferredSystemGestureState = .disabled home.pressedChangedHandler = { [weak self] _, _, pressed in MainActor.assumeIsolated { self?.sendGuide(down: pressed) } } @@ -192,6 +206,11 @@ public final class GamepadCapture { if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } + // The share/create/capture element (Xbox Series share, a clone pad's screenshot button — + // e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose + // the create button BOTH as buttonOptions and as the share element this OR is harmless — + // same wire bit. + if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }