Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,20 @@ Run with debug logging to see GTK and initialization output:
z13gui --debug
```

**Touchscreen or touchpad stops responding while the drawer is open**

Fixed in 1.4.1. On 1.4.0 and earlier, z13gui could mistake the machine's own
touchpad and touchscreen for a game controller's touchpad and take exclusive
access (`EVIOCGRAB`) to them for as long as the drawer was open, which stopped
touch input reaching the desktop entirely. A stylus was unaffected, and pressing
`Esc` to dismiss the drawer gave the devices back.

Only users whose account can open those device nodes were affected — normally
that means membership of the `input` group, since stock udev rules grant the
session user access to joysticks but not to touch devices.

Upgrade to 1.4.1.

**Gamescope: controller input not suppressed while drawer is open**

Grant BPF capabilities so z13gui can block controller input at the kernel level:
Expand Down
235 changes: 190 additions & 45 deletions internal/gui/gamepad/gamepad.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
// Device classification:
// - gamepad: full controllers (Xbox, PS, Switch, virtual Steam devices) →
// read events + EVIOCGRAB to suppress background game input
// - grab-only: related input devices (PS touchpad) → EVIOCGRAB only,
// events discarded (prevents touchpad acting as mouse in background)
// - grab-only: a controller's *own* secondary devices (the PS touchpad) →
// EVIOCGRAB only, events discarded (prevents touchpad acting as mouse in
// background). A multitouch device only qualifies when a gamepad from the
// same physical controller is already tracked; see classify.
// - ignored: accelerometers/gyro (INPUT_PROP_ACCELEROMETER), keyboards,
// mice, and other non-gamepad devices
// mice, the machine's own touchpad and touchscreen, and everything else
//
// Permissions: on modern systemd (Arch, Fedora, Ubuntu 22.04+), the uaccess
// udev tag in 70-uaccess.rules grants the active session user ACL access to
Expand All @@ -24,6 +26,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -179,12 +182,29 @@ func (r *Reader) SetGrabbed(seq uint64, grab bool) {
}

// scan enumerates /dev/input/event* and starts readers for new devices.
//
// It opens and inspects everything before classifying anything, because a
// controller's touchpad can only be recognised once its gamepad node is known and
// /dev/input/event* enumerates in node order rather than device order — the
// touchpad routinely comes first. Devices that stay unmatched are simply ignored
// and left untracked, so if the controller is plugged in later the 5s rescan
// picks its touchpad up then.
func (r *Reader) scan() {
paths, err := evdev.ListDevicePaths()
if err != nil {
slog.Debug("gamepad: scan failed", "err", err)
return
}

type candidate struct {
path string
dev *evdev.InputDevice
info deviceInfo
}
var (
candidates []candidate
gamepads []deviceInfo
)
for _, p := range paths {
r.mu.Lock()
_, inDevices := r.devices[p.Path]
Expand All @@ -193,29 +213,50 @@ func (r *Reader) scan() {
if inDevices || inGrabOnly {
continue
}
r.tryOpen(p.Path)
dev, err := evdev.OpenWithFlags(p.Path, os.O_RDONLY)
if err != nil {
continue
}
info := inspect(dev)
candidates = append(candidates, candidate{path: p.Path, dev: dev, info: info})
// The gamepad verdict never depends on the sibling list, so this pass can
// settle it and build the list the touchpad verdict needs.
if classify(info, nil) == deviceGamepad {
gamepads = append(gamepads, info)
}
}
// Controllers found by an earlier scan count too: the touchpad may be the only
// new node this time round, after a reconnect.
gamepads = append(gamepads, r.trackedGamepads()...)

for _, c := range candidates {
r.adopt(c.path, c.dev, c.info, classify(c.info, gamepads))
}
}

// tryOpen opens a device, classifies it, and starts the appropriate handler.
func (r *Reader) tryOpen(path string) {
dev, err := evdev.OpenWithFlags(path, os.O_RDONLY)
if err != nil {
return
// trackedGamepads returns the identity of every gamepad currently tracked.
func (r *Reader) trackedGamepads() []deviceInfo {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]deviceInfo, 0, len(r.devices))
for _, dev := range r.devices {
out = append(out, inspect(dev))
}
return out
}

class := classifyDevice(dev)
// adopt starts the handler for a classified device, or closes it if it is not one
// we care about.
func (r *Reader) adopt(path string, dev *evdev.InputDevice, info deviceInfo, class deviceClass) {
if class == deviceIgnore {
_ = dev.Close()
return
}

name, _ := dev.Name()
id, _ := dev.InputID()
attrs := []any{
"path", path,
"name", name,
"id", fmt.Sprintf("%04x:%04x", id.Vendor, id.Product),
"name", info.name,
"id", fmt.Sprintf("%04x:%04x", info.id.Vendor, info.id.Product),
}

r.mu.Lock()
Expand Down Expand Up @@ -244,52 +285,156 @@ func (r *Reader) tryOpen(path string) {
}
}

// classifyDevice determines how to handle an evdev device.
func classifyDevice(dev *evdev.InputDevice) deviceClass {
// Steam's virtual gamepad, the controller Steam Input presents to games.
const (
steamVendor = 0x28DE
steamVirtualProduct = 0x11FF
)

// deviceInfo is the identity and capability set classification reads. Keeping it
// separate from the open device is what lets classify be a pure function, and so
// the only way to test classification without the hardware in hand.
type deviceInfo struct {
name string
id evdev.InputID
props []evdev.EvProp
keys []evdev.EvCode
abs []evdev.EvCode
uniq string // EVIOCGUNIQ: serial or MAC; most devices report nothing
phys string // EVIOCGPHYS: physical port path
}

// inspect reads what classify needs. Not every driver implements the EVIOCG*
// ioctls behind uniq and phys, and an error there means only that this device
// does not report one — classify treats an empty string as "unknown" rather than
// as a mismatch.
func inspect(dev *evdev.InputDevice) deviceInfo {
name, _ := dev.Name()
id, _ := dev.InputID()
uniq, _ := dev.UniqueID()
phys, _ := dev.PhysicalLocation()
return deviceInfo{
name: name,
id: id,
props: dev.Properties(),
keys: dev.CapableEvents(evdev.EV_KEY),
abs: dev.CapableEvents(evdev.EV_ABS),
uniq: uniq,
phys: phys,
}
}

// classify determines how to handle a device. gamepads carries the devices
// already classified as deviceGamepad, which is what tells a controller's own
// touchpad apart from the machine's; callers that have not enumerated the
// gamepads yet may pass nil, and get deviceIgnore for touchpads.
func classify(d deviceInfo, gamepads []deviceInfo) deviceClass {
// Skip accelerometers/gyro (PS motion sensors). High-frequency events,
// not routable to game input — grabbing is wasteful.
for _, p := range dev.Properties() {
if p == evdev.INPUT_PROP_ACCELEROMETER {
return deviceIgnore
}
if hasProp(d, evdev.INPUT_PROP_ACCELEROMETER) {
return deviceIgnore
}

// Check for gamepad button capabilities (Xbox, PS, Switch, virtual).
keys := dev.CapableEvents(evdev.EV_KEY)
hasGamepadBtn := false
for _, k := range keys {
for _, gb := range gamepadButtons {
if k == gb {
hasGamepadBtn = true
break
}
}
if hasGamepadBtn {
break
}
}
if hasGamepadBtn {
// Steam virtual gamepad (VID 28de, PID 11ff) — grab to block game's
// evdev reader, but don't read events (we read the physical device).
id, err := dev.InputID()
if err == nil && id.Vendor == 0x28DE && id.Product == 0x11FF {
if hasGamepadButton(d) {
// Steam virtual gamepad — grab to block game's evdev reader, but don't
// read events (we read the physical device).
if d.id.Vendor == steamVendor && d.id.Product == steamVirtualProduct {
return deviceGrabOnly
}
return deviceGamepad
}

// Check for touchpad (PS controller touchpad): has multitouch but no
// gamepad buttons. Must be grabbed to prevent it acting as a mouse.
abs := dev.CapableEvents(evdev.EV_ABS)
for _, a := range abs {
if a == evdev.ABS_MT_POSITION_X {
// A controller's own touchpad: multitouch, but no gamepad buttons of its own
// because they live on the sibling node. Grab it so it stops acting as a
// mouse while the drawer is open.
//
// Multitouch alone does not identify one. The machine's own touchpad and
// touchscreen answer that test just as well, and grabbing those takes them
// from the compositor for as long as the drawer is open — touch input dies
// system-wide, leaving the keyboard as the only way to dismiss the drawer and
// get it back (issue #18). So the device has to be shown to belong to a
// controller before it is grabbed.
if !hasCode(d.abs, evdev.ABS_MT_POSITION_X) {
return deviceIgnore
}
// A touchscreen reports coordinates in screen space. No controller touchpad
// does, so this rules the built-in panel out ahead of any matching.
if hasProp(d, evdev.INPUT_PROP_DIRECT) {
return deviceIgnore
}
for _, g := range gamepads {
if sameController(d, g) {
return deviceGrabOnly
}
}

return deviceIgnore
}

// sameController reports whether two device nodes belong to one physical
// controller.
//
// Every input node a HID driver creates inherits bus, vendor and product from the
// parent hid_device, so a controller's touchpad always carries the same
// vendor:product as its gamepad node (DualSense 054c:0ce6, DualShock 4 054c:09cc).
// That is what carries the decision: the machine's own touchpad and touchscreen
// have their own vendor IDs and cannot match a controller's.
//
// uniq (the controller's MAC) and phys then separate two controllers of the same
// model. They only refine the answer — mistaking one attached DualSense's
// touchpad for another's still grabs a controller touchpad, which is the intended
// outcome either way — so a device reporting neither still matches on
// vendor:product alone.
func sameController(a, b deviceInfo) bool {
if a.id.Vendor != b.id.Vendor || a.id.Product != b.id.Product {
return false
}
if a.uniq != "" && b.uniq != "" {
return a.uniq == b.uniq
}
if a.phys != "" && b.phys != "" {
return physRoot(a.phys) == physRoot(b.phys)
}
return true
}

// physRoot drops the per-node suffix from an EVIOCGPHYS path, leaving the part
// every node of one device shares: "usb-0000:0a:00.3-2/input0" → "usb-0000:0a:00.3-2".
func physRoot(phys string) string {
if i := strings.IndexByte(phys, '/'); i >= 0 {
return phys[:i]
}
return phys
}

// hasGamepadButton reports whether d carries any button that marks it a gamepad
// (Xbox, PS, Switch, virtual).
func hasGamepadButton(d deviceInfo) bool {
for _, gb := range gamepadButtons {
if hasCode(d.keys, gb) {
return true
}
}
return false
}

func hasProp(d deviceInfo, want evdev.EvProp) bool {
for _, p := range d.props {
if p == want {
return true
}
}
return false
}

func hasCode(codes []evdev.EvCode, want evdev.EvCode) bool {
for _, c := range codes {
if c == want {
return true
}
}
return false
}

// repeat timing constants.
const (
repeatInitial = 400 * time.Millisecond
Expand Down
Loading