feat(drm): libseat session, KMS dumb-buffer output and libinput backend
- commit
- 0679ea512aec2ea3d8360c04f5dfb9feac0137e1
- author
- brice · · 2026-09-24 08:00 UTC
- parent
- 6d5f7c4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +1276 -11
M internal/adapters/config/config.go +1 -1
| @@ -18,7 +18,7 @@ var defaultBinds = map[string]string{ | |||
| 18 | 18 | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", | |
| 19 | 19 | } | |
| 20 | 20 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | |
| 21 | - | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true} | |
| 21 | + | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 22 | 22 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 23 | 23 | ||
| 24 | 24 | func DefaultPath() string { | |
| @@ -18,7 +18,7 @@ var defaultBinds = map[string]string{ | |||||
| 18 | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", | 18 | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", | ||
| 19 | } | 19 | } | ||
| 20 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | 20 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | ||
| 21 | - | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true} | 21 | + | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} |
| 22 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 22 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 23 | 23 | ||||
| 24 | func DefaultPath() string { | 24 | func DefaultPath() string { | ||
A internal/adapters/drm/kms.go +303 -0
| @@ -0,0 +1,303 @@ | |||
| 1 | + | // Package drm drives one KMS output with raw ioctls and dumb buffers. | |
| 2 | + | package drm | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bytes" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "image" | |
| 9 | + | "unsafe" | |
| 10 | + | ||
| 11 | + | "golang.org/x/sys/unix" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)). | |
| 15 | + | const ( | |
| 16 | + | ioctlGetResources = 0xC04064A0 | |
| 17 | + | ioctlGetCrtc = 0xC06864A1 | |
| 18 | + | ioctlSetCrtc = 0xC06864A2 | |
| 19 | + | ioctlGetEncoder = 0xC01464A6 | |
| 20 | + | ioctlGetConnector = 0xC05064A7 | |
| 21 | + | ioctlAddFB = 0xC01C64AE | |
| 22 | + | ioctlRmFB = 0xC00464AF | |
| 23 | + | ioctlPageFlip = 0xC01864B0 | |
| 24 | + | ioctlCreateDumb = 0xC02064B2 | |
| 25 | + | ioctlMapDumb = 0xC01064B3 | |
| 26 | + | ioctlDestroyDumb = 0xC00464B4 | |
| 27 | + | ||
| 28 | + | pageFlipEvent = 1 | |
| 29 | + | eventFlipDone = 2 | |
| 30 | + | modeTypePrefered = 1 << 3 | |
| 31 | + | connected = 1 | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | type cardRes struct { | |
| 35 | + | fbs, crtcs, connectors, encoders uint64 | |
| 36 | + | countFbs, countCrtcs, countConns, countEncoders uint32 | |
| 37 | + | minW, maxW, minH, maxH uint32 | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type modeInfo struct { | |
| 41 | + | Clock uint32 | |
| 42 | + | HDisplay, HSyncStart, HSyncEnd, HTotal uint16 | |
| 43 | + | HSkew uint16 | |
| 44 | + | VDisplay, VSyncStart, VSyncEnd, VTotal uint16 | |
| 45 | + | VScan uint16 | |
| 46 | + | VRefresh, Flags, Type uint32 | |
| 47 | + | Name [32]byte | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (m modeInfo) String() string { | |
| 51 | + | return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | type getConnector struct { | |
| 55 | + | encoders, modes, props, propValues uint64 | |
| 56 | + | countModes, countProps, countEncoders uint32 | |
| 57 | + | encoderID, connectorID, connType, typeID uint32 | |
| 58 | + | connection, mmW, mmH, subpixel uint32 | |
| 59 | + | _ uint32 | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 } | |
| 63 | + | ||
| 64 | + | type modeCrtc struct { | |
| 65 | + | setConnectors uint64 | |
| 66 | + | countConnectors uint32 | |
| 67 | + | crtcID, fbID uint32 | |
| 68 | + | x, y uint32 | |
| 69 | + | gammaSize uint32 | |
| 70 | + | modeValid uint32 | |
| 71 | + | mode modeInfo | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type pageFlip struct { | |
| 75 | + | crtcID, fbID, flags, reserved uint32 | |
| 76 | + | userData uint64 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type createDumb struct { | |
| 80 | + | height, width, bpp, flags uint32 | |
| 81 | + | handle, pitch uint32 | |
| 82 | + | size uint64 | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | type mapDumb struct { | |
| 86 | + | handle, pad uint32 | |
| 87 | + | offset uint64 | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 } | |
| 91 | + | ||
| 92 | + | func ioctl(fd int, req uintptr, arg unsafe.Pointer) error { | |
| 93 | + | for { | |
| 94 | + | _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg)) | |
| 95 | + | if e == unix.EINTR || e == unix.EAGAIN { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | if e != 0 { | |
| 99 | + | return e | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // connector is the subset of KMS connector state used to pick an output. | |
| 106 | + | type connector struct { | |
| 107 | + | id, encoderID uint32 | |
| 108 | + | name string | |
| 109 | + | connected bool | |
| 110 | + | modes []modeInfo | |
| 111 | + | encoders []uint32 | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"} | |
| 115 | + | ||
| 116 | + | // pickConnector returns the first connected connector and its preferred (else first) mode. | |
| 117 | + | func pickConnector(conns []connector) (connector, modeInfo, error) { | |
| 118 | + | for _, c := range conns { | |
| 119 | + | if !c.connected || len(c.modes) == 0 { | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | for _, m := range c.modes { | |
| 123 | + | if m.Type&modeTypePrefered != 0 { | |
| 124 | + | return c, m, nil | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return c, c.modes[0], nil | |
| 128 | + | } | |
| 129 | + | return connector{}, modeInfo{}, errors.New("no connected display") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func resources(fd int) (crtcs, conns []uint32, err error) { | |
| 133 | + | var r cardRes | |
| 134 | + | if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil { | |
| 135 | + | return nil, nil, fmt.Errorf("get resources: %w", err) | |
| 136 | + | } | |
| 137 | + | crtcs = make([]uint32, r.countCrtcs) | |
| 138 | + | conns = make([]uint32, r.countConns) | |
| 139 | + | r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns} | |
| 140 | + | if len(crtcs) > 0 { | |
| 141 | + | r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0]))) | |
| 142 | + | } | |
| 143 | + | if len(conns) > 0 { | |
| 144 | + | r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0]))) | |
| 145 | + | } | |
| 146 | + | if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil { | |
| 147 | + | return nil, nil, fmt.Errorf("get resources: %w", err) | |
| 148 | + | } | |
| 149 | + | return crtcs[:r.countCrtcs], conns[:r.countConns], nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func readConnector(fd int, id uint32) (connector, error) { | |
| 153 | + | g := getConnector{connectorID: id} | |
| 154 | + | if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil { | |
| 155 | + | return connector{}, err | |
| 156 | + | } | |
| 157 | + | modes := make([]modeInfo, g.countModes) | |
| 158 | + | encs := make([]uint32, g.countEncoders) | |
| 159 | + | g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders} | |
| 160 | + | if len(modes) > 0 { | |
| 161 | + | g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0]))) | |
| 162 | + | } | |
| 163 | + | if len(encs) > 0 { | |
| 164 | + | g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0]))) | |
| 165 | + | } | |
| 166 | + | if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil { | |
| 167 | + | return connector{}, err | |
| 168 | + | } | |
| 169 | + | name := connectorTypes[g2.connType] | |
| 170 | + | if name == "" { | |
| 171 | + | name = "Unknown" | |
| 172 | + | } | |
| 173 | + | return connector{ | |
| 174 | + | id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID), | |
| 175 | + | connected: g2.connection == connected, | |
| 176 | + | modes: modes[:min(int(g2.countModes), len(modes))], | |
| 177 | + | encoders: encs[:min(int(g2.countEncoders), len(encs))], | |
| 178 | + | }, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows. | |
| 182 | + | func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) { | |
| 183 | + | encs := c.encoders | |
| 184 | + | if c.encoderID != 0 { | |
| 185 | + | encs = append([]uint32{c.encoderID}, encs...) | |
| 186 | + | } | |
| 187 | + | for _, id := range encs { | |
| 188 | + | e := getEncoder{encoderID: id} | |
| 189 | + | if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | if id == c.encoderID && e.crtcID != 0 { | |
| 193 | + | return e.crtcID, nil | |
| 194 | + | } | |
| 195 | + | for i, crtc := range crtcs { | |
| 196 | + | if e.possibleCrtcs&(1<<i) != 0 { | |
| 197 | + | return crtc, nil | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | return 0, fmt.Errorf("no CRTC for %s", c.name) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | type dumbBuffer struct { | |
| 205 | + | handle, fbID, pitch uint32 | |
| 206 | + | mem []byte | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func newDumb(fd int, w, h int) (*dumbBuffer, error) { | |
| 210 | + | c := createDumb{width: uint32(w), height: uint32(h), bpp: 32} | |
| 211 | + | if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil { | |
| 212 | + | return nil, fmt.Errorf("create dumb: %w", err) | |
| 213 | + | } | |
| 214 | + | b := &dumbBuffer{handle: c.handle, pitch: c.pitch} | |
| 215 | + | f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle} | |
| 216 | + | if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil { | |
| 217 | + | b.destroy(fd) | |
| 218 | + | return nil, fmt.Errorf("add fb: %w", err) | |
| 219 | + | } | |
| 220 | + | b.fbID = f.fbID | |
| 221 | + | m := mapDumb{handle: c.handle} | |
| 222 | + | if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil { | |
| 223 | + | b.destroy(fd) | |
| 224 | + | return nil, fmt.Errorf("map dumb: %w", err) | |
| 225 | + | } | |
| 226 | + | mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |
| 227 | + | if err != nil { | |
| 228 | + | b.destroy(fd) | |
| 229 | + | return nil, fmt.Errorf("mmap dumb: %w", err) | |
| 230 | + | } | |
| 231 | + | b.mem = mem | |
| 232 | + | return b, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (b *dumbBuffer) destroy(fd int) { | |
| 236 | + | if b.mem != nil { | |
| 237 | + | _ = unix.Munmap(b.mem) | |
| 238 | + | b.mem = nil | |
| 239 | + | } | |
| 240 | + | if b.fbID != 0 { | |
| 241 | + | id := b.fbID | |
| 242 | + | _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id)) | |
| 243 | + | b.fbID = 0 | |
| 244 | + | } | |
| 245 | + | h := b.handle | |
| 246 | + | _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h)) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // copyXRGB writes RGBA pixels into an XRGB8888 (little-endian BGRX) buffer. | |
| 250 | + | func copyXRGB(dst []byte, pitch int, img *image.RGBA) { | |
| 251 | + | w, h := img.Rect.Dx(), img.Rect.Dy() | |
| 252 | + | for y := 0; y < h; y++ { | |
| 253 | + | src := img.Pix[y*img.Stride : y*img.Stride+w*4] | |
| 254 | + | row := dst[y*pitch : y*pitch+w*4] | |
| 255 | + | for x := 0; x < w*4; x += 4 { | |
| 256 | + | row[x], row[x+1], row[x+2], row[x+3] = src[x+2], src[x+1], src[x], 0xff | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error { | |
| 262 | + | ids := []uint32{conn} | |
| 263 | + | c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))} | |
| 264 | + | if mode != nil { | |
| 265 | + | c.mode, c.modeValid = *mode, 1 | |
| 266 | + | } | |
| 267 | + | return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c)) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func getCrtc(fd int, crtc uint32) (modeCrtc, error) { | |
| 271 | + | c := modeCrtc{crtcID: crtc} | |
| 272 | + | err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c)) | |
| 273 | + | return c, err | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func flip(fd int, crtc, fb uint32) error { | |
| 277 | + | p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent} | |
| 278 | + | return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p)) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // countFlipEvents parses DRM events read from the card fd. | |
| 282 | + | func countFlipEvents(buf []byte) int { | |
| 283 | + | n := 0 | |
| 284 | + | for len(buf) >= 8 { | |
| 285 | + | typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24 | |
| 286 | + | length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24) | |
| 287 | + | if length < 8 || length > len(buf) { | |
| 288 | + | break | |
| 289 | + | } | |
| 290 | + | if typ == eventFlipDone { | |
| 291 | + | n++ | |
| 292 | + | } | |
| 293 | + | buf = buf[length:] | |
| 294 | + | } | |
| 295 | + | return n | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func cstr(b []byte) string { | |
| 299 | + | if i := bytes.IndexByte(b, 0); i >= 0 { | |
| 300 | + | return string(b[:i]) | |
| 301 | + | } | |
| 302 | + | return string(b) | |
| 303 | + | } | |
| @@ -0,0 +1,303 @@ | |||||
| 1 | + | // Package drm drives one KMS output with raw ioctls and dumb buffers. | |||
| 2 | + | package drm | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "bytes" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "image" | |||
| 9 | + | "unsafe" | |||
| 10 | + | ||||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)). | |||
| 15 | + | const ( | |||
| 16 | + | ioctlGetResources = 0xC04064A0 | |||
| 17 | + | ioctlGetCrtc = 0xC06864A1 | |||
| 18 | + | ioctlSetCrtc = 0xC06864A2 | |||
| 19 | + | ioctlGetEncoder = 0xC01464A6 | |||
| 20 | + | ioctlGetConnector = 0xC05064A7 | |||
| 21 | + | ioctlAddFB = 0xC01C64AE | |||
| 22 | + | ioctlRmFB = 0xC00464AF | |||
| 23 | + | ioctlPageFlip = 0xC01864B0 | |||
| 24 | + | ioctlCreateDumb = 0xC02064B2 | |||
| 25 | + | ioctlMapDumb = 0xC01064B3 | |||
| 26 | + | ioctlDestroyDumb = 0xC00464B4 | |||
| 27 | + | ||||
| 28 | + | pageFlipEvent = 1 | |||
| 29 | + | eventFlipDone = 2 | |||
| 30 | + | modeTypePrefered = 1 << 3 | |||
| 31 | + | connected = 1 | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | type cardRes struct { | |||
| 35 | + | fbs, crtcs, connectors, encoders uint64 | |||
| 36 | + | countFbs, countCrtcs, countConns, countEncoders uint32 | |||
| 37 | + | minW, maxW, minH, maxH uint32 | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type modeInfo struct { | |||
| 41 | + | Clock uint32 | |||
| 42 | + | HDisplay, HSyncStart, HSyncEnd, HTotal uint16 | |||
| 43 | + | HSkew uint16 | |||
| 44 | + | VDisplay, VSyncStart, VSyncEnd, VTotal uint16 | |||
| 45 | + | VScan uint16 | |||
| 46 | + | VRefresh, Flags, Type uint32 | |||
| 47 | + | Name [32]byte | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (m modeInfo) String() string { | |||
| 51 | + | return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type getConnector struct { | |||
| 55 | + | encoders, modes, props, propValues uint64 | |||
| 56 | + | countModes, countProps, countEncoders uint32 | |||
| 57 | + | encoderID, connectorID, connType, typeID uint32 | |||
| 58 | + | connection, mmW, mmH, subpixel uint32 | |||
| 59 | + | _ uint32 | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 } | |||
| 63 | + | ||||
| 64 | + | type modeCrtc struct { | |||
| 65 | + | setConnectors uint64 | |||
| 66 | + | countConnectors uint32 | |||
| 67 | + | crtcID, fbID uint32 | |||
| 68 | + | x, y uint32 | |||
| 69 | + | gammaSize uint32 | |||
| 70 | + | modeValid uint32 | |||
| 71 | + | mode modeInfo | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | type pageFlip struct { | |||
| 75 | + | crtcID, fbID, flags, reserved uint32 | |||
| 76 | + | userData uint64 | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | type createDumb struct { | |||
| 80 | + | height, width, bpp, flags uint32 | |||
| 81 | + | handle, pitch uint32 | |||
| 82 | + | size uint64 | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | type mapDumb struct { | |||
| 86 | + | handle, pad uint32 | |||
| 87 | + | offset uint64 | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 } | |||
| 91 | + | ||||
| 92 | + | func ioctl(fd int, req uintptr, arg unsafe.Pointer) error { | |||
| 93 | + | for { | |||
| 94 | + | _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg)) | |||
| 95 | + | if e == unix.EINTR || e == unix.EAGAIN { | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | if e != 0 { | |||
| 99 | + | return e | |||
| 100 | + | } | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // connector is the subset of KMS connector state used to pick an output. | |||
| 106 | + | type connector struct { | |||
| 107 | + | id, encoderID uint32 | |||
| 108 | + | name string | |||
| 109 | + | connected bool | |||
| 110 | + | modes []modeInfo | |||
| 111 | + | encoders []uint32 | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"} | |||
| 115 | + | ||||
| 116 | + | // pickConnector returns the first connected connector and its preferred (else first) mode. | |||
| 117 | + | func pickConnector(conns []connector) (connector, modeInfo, error) { | |||
| 118 | + | for _, c := range conns { | |||
| 119 | + | if !c.connected || len(c.modes) == 0 { | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | for _, m := range c.modes { | |||
| 123 | + | if m.Type&modeTypePrefered != 0 { | |||
| 124 | + | return c, m, nil | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return c, c.modes[0], nil | |||
| 128 | + | } | |||
| 129 | + | return connector{}, modeInfo{}, errors.New("no connected display") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func resources(fd int) (crtcs, conns []uint32, err error) { | |||
| 133 | + | var r cardRes | |||
| 134 | + | if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil { | |||
| 135 | + | return nil, nil, fmt.Errorf("get resources: %w", err) | |||
| 136 | + | } | |||
| 137 | + | crtcs = make([]uint32, r.countCrtcs) | |||
| 138 | + | conns = make([]uint32, r.countConns) | |||
| 139 | + | r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns} | |||
| 140 | + | if len(crtcs) > 0 { | |||
| 141 | + | r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0]))) | |||
| 142 | + | } | |||
| 143 | + | if len(conns) > 0 { | |||
| 144 | + | r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0]))) | |||
| 145 | + | } | |||
| 146 | + | if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil { | |||
| 147 | + | return nil, nil, fmt.Errorf("get resources: %w", err) | |||
| 148 | + | } | |||
| 149 | + | return crtcs[:r.countCrtcs], conns[:r.countConns], nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func readConnector(fd int, id uint32) (connector, error) { | |||
| 153 | + | g := getConnector{connectorID: id} | |||
| 154 | + | if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil { | |||
| 155 | + | return connector{}, err | |||
| 156 | + | } | |||
| 157 | + | modes := make([]modeInfo, g.countModes) | |||
| 158 | + | encs := make([]uint32, g.countEncoders) | |||
| 159 | + | g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders} | |||
| 160 | + | if len(modes) > 0 { | |||
| 161 | + | g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0]))) | |||
| 162 | + | } | |||
| 163 | + | if len(encs) > 0 { | |||
| 164 | + | g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0]))) | |||
| 165 | + | } | |||
| 166 | + | if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil { | |||
| 167 | + | return connector{}, err | |||
| 168 | + | } | |||
| 169 | + | name := connectorTypes[g2.connType] | |||
| 170 | + | if name == "" { | |||
| 171 | + | name = "Unknown" | |||
| 172 | + | } | |||
| 173 | + | return connector{ | |||
| 174 | + | id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID), | |||
| 175 | + | connected: g2.connection == connected, | |||
| 176 | + | modes: modes[:min(int(g2.countModes), len(modes))], | |||
| 177 | + | encoders: encs[:min(int(g2.countEncoders), len(encs))], | |||
| 178 | + | }, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows. | |||
| 182 | + | func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) { | |||
| 183 | + | encs := c.encoders | |||
| 184 | + | if c.encoderID != 0 { | |||
| 185 | + | encs = append([]uint32{c.encoderID}, encs...) | |||
| 186 | + | } | |||
| 187 | + | for _, id := range encs { | |||
| 188 | + | e := getEncoder{encoderID: id} | |||
| 189 | + | if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil { | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | if id == c.encoderID && e.crtcID != 0 { | |||
| 193 | + | return e.crtcID, nil | |||
| 194 | + | } | |||
| 195 | + | for i, crtc := range crtcs { | |||
| 196 | + | if e.possibleCrtcs&(1<<i) != 0 { | |||
| 197 | + | return crtc, nil | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | return 0, fmt.Errorf("no CRTC for %s", c.name) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | type dumbBuffer struct { | |||
| 205 | + | handle, fbID, pitch uint32 | |||
| 206 | + | mem []byte | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func newDumb(fd int, w, h int) (*dumbBuffer, error) { | |||
| 210 | + | c := createDumb{width: uint32(w), height: uint32(h), bpp: 32} | |||
| 211 | + | if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil { | |||
| 212 | + | return nil, fmt.Errorf("create dumb: %w", err) | |||
| 213 | + | } | |||
| 214 | + | b := &dumbBuffer{handle: c.handle, pitch: c.pitch} | |||
| 215 | + | f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle} | |||
| 216 | + | if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil { | |||
| 217 | + | b.destroy(fd) | |||
| 218 | + | return nil, fmt.Errorf("add fb: %w", err) | |||
| 219 | + | } | |||
| 220 | + | b.fbID = f.fbID | |||
| 221 | + | m := mapDumb{handle: c.handle} | |||
| 222 | + | if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil { | |||
| 223 | + | b.destroy(fd) | |||
| 224 | + | return nil, fmt.Errorf("map dumb: %w", err) | |||
| 225 | + | } | |||
| 226 | + | mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) | |||
| 227 | + | if err != nil { | |||
| 228 | + | b.destroy(fd) | |||
| 229 | + | return nil, fmt.Errorf("mmap dumb: %w", err) | |||
| 230 | + | } | |||
| 231 | + | b.mem = mem | |||
| 232 | + | return b, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (b *dumbBuffer) destroy(fd int) { | |||
| 236 | + | if b.mem != nil { | |||
| 237 | + | _ = unix.Munmap(b.mem) | |||
| 238 | + | b.mem = nil | |||
| 239 | + | } | |||
| 240 | + | if b.fbID != 0 { | |||
| 241 | + | id := b.fbID | |||
| 242 | + | _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id)) | |||
| 243 | + | b.fbID = 0 | |||
| 244 | + | } | |||
| 245 | + | h := b.handle | |||
| 246 | + | _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h)) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // copyXRGB writes RGBA pixels into an XRGB8888 (little-endian BGRX) buffer. | |||
| 250 | + | func copyXRGB(dst []byte, pitch int, img *image.RGBA) { | |||
| 251 | + | w, h := img.Rect.Dx(), img.Rect.Dy() | |||
| 252 | + | for y := 0; y < h; y++ { | |||
| 253 | + | src := img.Pix[y*img.Stride : y*img.Stride+w*4] | |||
| 254 | + | row := dst[y*pitch : y*pitch+w*4] | |||
| 255 | + | for x := 0; x < w*4; x += 4 { | |||
| 256 | + | row[x], row[x+1], row[x+2], row[x+3] = src[x+2], src[x+1], src[x], 0xff | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error { | |||
| 262 | + | ids := []uint32{conn} | |||
| 263 | + | c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))} | |||
| 264 | + | if mode != nil { | |||
| 265 | + | c.mode, c.modeValid = *mode, 1 | |||
| 266 | + | } | |||
| 267 | + | return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c)) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func getCrtc(fd int, crtc uint32) (modeCrtc, error) { | |||
| 271 | + | c := modeCrtc{crtcID: crtc} | |||
| 272 | + | err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c)) | |||
| 273 | + | return c, err | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func flip(fd int, crtc, fb uint32) error { | |||
| 277 | + | p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent} | |||
| 278 | + | return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p)) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // countFlipEvents parses DRM events read from the card fd. | |||
| 282 | + | func countFlipEvents(buf []byte) int { | |||
| 283 | + | n := 0 | |||
| 284 | + | for len(buf) >= 8 { | |||
| 285 | + | typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24 | |||
| 286 | + | length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24) | |||
| 287 | + | if length < 8 || length > len(buf) { | |||
| 288 | + | break | |||
| 289 | + | } | |||
| 290 | + | if typ == eventFlipDone { | |||
| 291 | + | n++ | |||
| 292 | + | } | |||
| 293 | + | buf = buf[length:] | |||
| 294 | + | } | |||
| 295 | + | return n | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func cstr(b []byte) string { | |||
| 299 | + | if i := bytes.IndexByte(b, 0); i >= 0 { | |||
| 300 | + | return string(b[:i]) | |||
| 301 | + | } | |||
| 302 | + | return string(b) | |||
| 303 | + | } | |||
A internal/adapters/drm/kms_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "image" | |
| 6 | + | "testing" | |
| 7 | + | "unsafe" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestStructSizesMatchIoctls(t *testing.T) { | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | name string | |
| 13 | + | got uintptr | |
| 14 | + | req uintptr | |
| 15 | + | }{ | |
| 16 | + | {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources}, | |
| 17 | + | {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc}, | |
| 18 | + | {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder}, | |
| 19 | + | {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector}, | |
| 20 | + | {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB}, | |
| 21 | + | {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip}, | |
| 22 | + | {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb}, | |
| 23 | + | {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb}, | |
| 24 | + | } { | |
| 25 | + | if want := (tc.req >> 16) & 0x3fff; tc.got != want { | |
| 26 | + | t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | if unsafe.Sizeof(modeInfo{}) != 68 { | |
| 30 | + | t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{})) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestPickConnector(t *testing.T) { | |
| 35 | + | plain := modeInfo{HDisplay: 1280, VDisplay: 720} | |
| 36 | + | pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered} | |
| 37 | + | for _, tc := range []struct { | |
| 38 | + | name string | |
| 39 | + | conns []connector | |
| 40 | + | want string | |
| 41 | + | mode uint16 | |
| 42 | + | err bool | |
| 43 | + | }{ | |
| 44 | + | {"none", nil, "", 0, true}, | |
| 45 | + | {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false}, | |
| 46 | + | {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false}, | |
| 47 | + | {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true}, | |
| 48 | + | } { | |
| 49 | + | c, m, err := pickConnector(tc.conns) | |
| 50 | + | if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode { | |
| 51 | + | t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestCopyXRGB(t *testing.T) { | |
| 57 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 58 | + | copy(img.Pix, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) | |
| 59 | + | pitch := 12 | |
| 60 | + | dst := make([]byte, pitch*2) | |
| 61 | + | copyXRGB(dst, pitch, img) | |
| 62 | + | want := []byte{3, 2, 1, 255, 7, 6, 5, 255, 0, 0, 0, 0, 11, 10, 9, 255, 15, 14, 13, 255, 0, 0, 0, 0} | |
| 63 | + | for i := range want { | |
| 64 | + | if dst[i] != want[i] { | |
| 65 | + | t.Fatalf("byte %d = %d, want %d (%v)", i, dst[i], want[i], dst) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCountFlipEvents(t *testing.T) { | |
| 71 | + | ev := func(typ uint32, n int) []byte { | |
| 72 | + | b := make([]byte, n) | |
| 73 | + | binary.LittleEndian.PutUint32(b, typ) | |
| 74 | + | binary.LittleEndian.PutUint32(b[4:], uint32(n)) | |
| 75 | + | return b | |
| 76 | + | } | |
| 77 | + | buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...) | |
| 78 | + | if n := countFlipEvents(buf); n != 2 { | |
| 79 | + | t.Fatalf("got %d flips", n) | |
| 80 | + | } | |
| 81 | + | if n := countFlipEvents(buf[:20]); n != 0 { | |
| 82 | + | t.Fatalf("truncated: got %d", n) | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "image" | |||
| 6 | + | "testing" | |||
| 7 | + | "unsafe" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestStructSizesMatchIoctls(t *testing.T) { | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | got uintptr | |||
| 14 | + | req uintptr | |||
| 15 | + | }{ | |||
| 16 | + | {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources}, | |||
| 17 | + | {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc}, | |||
| 18 | + | {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder}, | |||
| 19 | + | {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector}, | |||
| 20 | + | {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB}, | |||
| 21 | + | {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip}, | |||
| 22 | + | {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb}, | |||
| 23 | + | {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb}, | |||
| 24 | + | } { | |||
| 25 | + | if want := (tc.req >> 16) & 0x3fff; tc.got != want { | |||
| 26 | + | t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | if unsafe.Sizeof(modeInfo{}) != 68 { | |||
| 30 | + | t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{})) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestPickConnector(t *testing.T) { | |||
| 35 | + | plain := modeInfo{HDisplay: 1280, VDisplay: 720} | |||
| 36 | + | pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered} | |||
| 37 | + | for _, tc := range []struct { | |||
| 38 | + | name string | |||
| 39 | + | conns []connector | |||
| 40 | + | want string | |||
| 41 | + | mode uint16 | |||
| 42 | + | err bool | |||
| 43 | + | }{ | |||
| 44 | + | {"none", nil, "", 0, true}, | |||
| 45 | + | {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false}, | |||
| 46 | + | {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false}, | |||
| 47 | + | {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true}, | |||
| 48 | + | } { | |||
| 49 | + | c, m, err := pickConnector(tc.conns) | |||
| 50 | + | if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode { | |||
| 51 | + | t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestCopyXRGB(t *testing.T) { | |||
| 57 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 58 | + | copy(img.Pix, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) | |||
| 59 | + | pitch := 12 | |||
| 60 | + | dst := make([]byte, pitch*2) | |||
| 61 | + | copyXRGB(dst, pitch, img) | |||
| 62 | + | want := []byte{3, 2, 1, 255, 7, 6, 5, 255, 0, 0, 0, 0, 11, 10, 9, 255, 15, 14, 13, 255, 0, 0, 0, 0} | |||
| 63 | + | for i := range want { | |||
| 64 | + | if dst[i] != want[i] { | |||
| 65 | + | t.Fatalf("byte %d = %d, want %d (%v)", i, dst[i], want[i], dst) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCountFlipEvents(t *testing.T) { | |||
| 71 | + | ev := func(typ uint32, n int) []byte { | |||
| 72 | + | b := make([]byte, n) | |||
| 73 | + | binary.LittleEndian.PutUint32(b, typ) | |||
| 74 | + | binary.LittleEndian.PutUint32(b[4:], uint32(n)) | |||
| 75 | + | return b | |||
| 76 | + | } | |||
| 77 | + | buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...) | |||
| 78 | + | if n := countFlipEvents(buf); n != 2 { | |||
| 79 | + | t.Fatalf("got %d flips", n) | |||
| 80 | + | } | |||
| 81 | + | if n := countFlipEvents(buf[:20]); n != 0 { | |||
| 82 | + | t.Fatalf("truncated: got %d", n) | |||
| 83 | + | } | |||
| 84 | + | } | |||
A internal/adapters/drm/output.go +219 -0
| @@ -0,0 +1,219 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "image" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/ports" | |
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Renderer draws a scene into an RGBA image of the output size. | |
| 16 | + | type Renderer interface { | |
| 17 | + | Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error | |
| 18 | + | Pixels() *image.RGBA | |
| 19 | + | Close() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Output is one KMS connector driven with two dumb buffers. | |
| 23 | + | type Output struct { | |
| 24 | + | fd int | |
| 25 | + | crtc uint32 | |
| 26 | + | conn connector | |
| 27 | + | mode modeInfo | |
| 28 | + | saved modeCrtc | |
| 29 | + | bufs [2]*dumbBuffer | |
| 30 | + | back int | |
| 31 | + | pending bool | |
| 32 | + | flipStart time.Time | |
| 33 | + | flips int | |
| 34 | + | log zerowrap.Logger | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Open picks the first connected connector on the card fd and allocates buffers. | |
| 38 | + | func Open(fd int, card string, log zerowrap.Logger) (*Output, error) { | |
| 39 | + | crtcs, ids, err := resources(fd) | |
| 40 | + | if err != nil { | |
| 41 | + | return nil, err | |
| 42 | + | } | |
| 43 | + | var conns []connector | |
| 44 | + | for _, id := range ids { | |
| 45 | + | c, err := readConnector(fd, id) | |
| 46 | + | if err != nil { | |
| 47 | + | log.Warn().Err(err).Uint32("connector", id).Msg("read connector") | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector") | |
| 51 | + | conns = append(conns, c) | |
| 52 | + | } | |
| 53 | + | c, mode, err := pickConnector(conns) | |
| 54 | + | if err != nil { | |
| 55 | + | return nil, fmt.Errorf("%s: %w", card, err) | |
| 56 | + | } | |
| 57 | + | crtc, err := pickCrtc(fd, c, crtcs) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 61 | + | o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log} | |
| 62 | + | o.saved, _ = getCrtc(fd, crtc) | |
| 63 | + | for i := range o.bufs { | |
| 64 | + | if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil { | |
| 65 | + | o.Close() | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output") | |
| 70 | + | return o, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (o *Output) Width() int { return int(o.mode.HDisplay) } | |
| 74 | + | func (o *Output) Height() int { return int(o.mode.VDisplay) } | |
| 75 | + | ||
| 76 | + | // modeset shows the front buffer; needed at start and after every VT resume. | |
| 77 | + | func (o *Output) modeset() error { | |
| 78 | + | o.pending = false | |
| 79 | + | front := o.bufs[1-o.back] | |
| 80 | + | if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil { | |
| 81 | + | return fmt.Errorf("set crtc: %w", err) | |
| 82 | + | } | |
| 83 | + | o.log.Info().Str("connector", o.conn.name).Msg("modeset") | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // present copies img into the back buffer and queues a page flip. | |
| 88 | + | func (o *Output) present(img *image.RGBA) error { | |
| 89 | + | b := o.bufs[o.back] | |
| 90 | + | copyXRGB(b.mem, int(b.pitch), img) | |
| 91 | + | if err := flip(o.fd, o.crtc, b.fbID); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | o.pending, o.flipStart = true, time.Now() | |
| 95 | + | o.back = 1 - o.back | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Close restores the CRTC state found at startup and frees buffers. | |
| 100 | + | func (o *Output) Close() { | |
| 101 | + | if o.saved.crtcID != 0 { | |
| 102 | + | ids := []uint32{o.conn.id} | |
| 103 | + | s := o.saved | |
| 104 | + | if s.modeValid != 0 { | |
| 105 | + | _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | for _, b := range o.bufs { | |
| 109 | + | if b != nil { | |
| 110 | + | b.destroy(o.fd) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Run renders scenes and flips until ctx ends. active reports seat enable/disable. | |
| 116 | + | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error { | |
| 117 | + | r, err := newRenderer(o.Width(), o.Height()) | |
| 118 | + | if err != nil { | |
| 119 | + | return fmt.Errorf("create renderer: %w", err) | |
| 120 | + | } | |
| 121 | + | defer r.Close() | |
| 122 | + | if err := o.modeset(); err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | events := make(chan int, 4) | |
| 126 | + | evErr := make(chan error, 1) | |
| 127 | + | go o.readEvents(ctx, events, evErr) | |
| 128 | + | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 129 | + | var scene ports.Scene | |
| 130 | + | haveScene, dirty, enabled := false, false, true | |
| 131 | + | frame := 0 | |
| 132 | + | stats := time.NewTicker(10 * time.Second) | |
| 133 | + | defer stats.Stop() | |
| 134 | + | for { | |
| 135 | + | select { | |
| 136 | + | case <-ctx.Done(): | |
| 137 | + | return nil | |
| 138 | + | case err := <-evErr: | |
| 139 | + | return err | |
| 140 | + | case on := <-active: | |
| 141 | + | if on && !enabled { | |
| 142 | + | if err := o.modeset(); err != nil { | |
| 143 | + | o.log.Error().Err(err).Msg("resume") | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | dirty = haveScene | |
| 147 | + | } | |
| 148 | + | enabled = on | |
| 149 | + | o.log.Info().Bool("enabled", on).Msg("output") | |
| 150 | + | case n := <-events: | |
| 151 | + | o.pending = false | |
| 152 | + | o.flips += n | |
| 153 | + | if d := time.Since(o.flipStart); d > 20*time.Millisecond { | |
| 154 | + | o.log.Info().Dur("flip_ms", d).Msg("slow flip") | |
| 155 | + | } | |
| 156 | + | case s := <-scenes: | |
| 157 | + | scene, haveScene, dirty = s, true, true | |
| 158 | + | case c := <-contents: | |
| 159 | + | if c.Pixels == nil { | |
| 160 | + | delete(surfaces, c.ID) | |
| 161 | + | } else { | |
| 162 | + | surfaces[c.ID] = c | |
| 163 | + | } | |
| 164 | + | dirty = true | |
| 165 | + | case <-stats.C: | |
| 166 | + | o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats") | |
| 167 | + | } | |
| 168 | + | if !dirty || !haveScene || !enabled || o.pending { | |
| 169 | + | continue | |
| 170 | + | } | |
| 171 | + | start := time.Now() | |
| 172 | + | if err := r.Render(scene, surfaces); err != nil { | |
| 173 | + | return fmt.Errorf("render frame: %w", err) | |
| 174 | + | } | |
| 175 | + | if err := o.present(r.Pixels()); err != nil { | |
| 176 | + | if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) { | |
| 177 | + | // Lost DRM master mid-switch; the seat disable follows. | |
| 178 | + | o.log.Warn().Err(err).Msg("flip refused") | |
| 179 | + | enabled = false | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | o.log.Error().Err(err).Msg("page flip") | |
| 183 | + | return fmt.Errorf("page flip: %w", err) | |
| 184 | + | } | |
| 185 | + | dirty = false | |
| 186 | + | frame++ | |
| 187 | + | o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Msg("frame") | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) { | |
| 192 | + | buf := make([]byte, 1024) | |
| 193 | + | for ctx.Err() == nil { | |
| 194 | + | fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}} | |
| 195 | + | n, err := unix.Poll(fds, 100) | |
| 196 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |
| 197 | + | errs <- fmt.Errorf("drm poll: %w", err) | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | if n <= 0 { | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | m, err := unix.Read(o.fd, buf) | |
| 204 | + | if err != nil { | |
| 205 | + | if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) { | |
| 206 | + | continue | |
| 207 | + | } | |
| 208 | + | errs <- fmt.Errorf("drm read: %w", err) | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | if c := countFlipEvents(buf[:m]); c > 0 { | |
| 212 | + | select { | |
| 213 | + | case events <- c: | |
| 214 | + | case <-ctx.Done(): | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| @@ -0,0 +1,219 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "image" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Renderer draws a scene into an RGBA image of the output size. | |||
| 16 | + | type Renderer interface { | |||
| 17 | + | Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error | |||
| 18 | + | Pixels() *image.RGBA | |||
| 19 | + | Close() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Output is one KMS connector driven with two dumb buffers. | |||
| 23 | + | type Output struct { | |||
| 24 | + | fd int | |||
| 25 | + | crtc uint32 | |||
| 26 | + | conn connector | |||
| 27 | + | mode modeInfo | |||
| 28 | + | saved modeCrtc | |||
| 29 | + | bufs [2]*dumbBuffer | |||
| 30 | + | back int | |||
| 31 | + | pending bool | |||
| 32 | + | flipStart time.Time | |||
| 33 | + | flips int | |||
| 34 | + | log zerowrap.Logger | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Open picks the first connected connector on the card fd and allocates buffers. | |||
| 38 | + | func Open(fd int, card string, log zerowrap.Logger) (*Output, error) { | |||
| 39 | + | crtcs, ids, err := resources(fd) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return nil, err | |||
| 42 | + | } | |||
| 43 | + | var conns []connector | |||
| 44 | + | for _, id := range ids { | |||
| 45 | + | c, err := readConnector(fd, id) | |||
| 46 | + | if err != nil { | |||
| 47 | + | log.Warn().Err(err).Uint32("connector", id).Msg("read connector") | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector") | |||
| 51 | + | conns = append(conns, c) | |||
| 52 | + | } | |||
| 53 | + | c, mode, err := pickConnector(conns) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, fmt.Errorf("%s: %w", card, err) | |||
| 56 | + | } | |||
| 57 | + | crtc, err := pickCrtc(fd, c, crtcs) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 61 | + | o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log} | |||
| 62 | + | o.saved, _ = getCrtc(fd, crtc) | |||
| 63 | + | for i := range o.bufs { | |||
| 64 | + | if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil { | |||
| 65 | + | o.Close() | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output") | |||
| 70 | + | return o, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (o *Output) Width() int { return int(o.mode.HDisplay) } | |||
| 74 | + | func (o *Output) Height() int { return int(o.mode.VDisplay) } | |||
| 75 | + | ||||
| 76 | + | // modeset shows the front buffer; needed at start and after every VT resume. | |||
| 77 | + | func (o *Output) modeset() error { | |||
| 78 | + | o.pending = false | |||
| 79 | + | front := o.bufs[1-o.back] | |||
| 80 | + | if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil { | |||
| 81 | + | return fmt.Errorf("set crtc: %w", err) | |||
| 82 | + | } | |||
| 83 | + | o.log.Info().Str("connector", o.conn.name).Msg("modeset") | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // present copies img into the back buffer and queues a page flip. | |||
| 88 | + | func (o *Output) present(img *image.RGBA) error { | |||
| 89 | + | b := o.bufs[o.back] | |||
| 90 | + | copyXRGB(b.mem, int(b.pitch), img) | |||
| 91 | + | if err := flip(o.fd, o.crtc, b.fbID); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | o.pending, o.flipStart = true, time.Now() | |||
| 95 | + | o.back = 1 - o.back | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Close restores the CRTC state found at startup and frees buffers. | |||
| 100 | + | func (o *Output) Close() { | |||
| 101 | + | if o.saved.crtcID != 0 { | |||
| 102 | + | ids := []uint32{o.conn.id} | |||
| 103 | + | s := o.saved | |||
| 104 | + | if s.modeValid != 0 { | |||
| 105 | + | _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | for _, b := range o.bufs { | |||
| 109 | + | if b != nil { | |||
| 110 | + | b.destroy(o.fd) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Run renders scenes and flips until ctx ends. active reports seat enable/disable. | |||
| 116 | + | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error { | |||
| 117 | + | r, err := newRenderer(o.Width(), o.Height()) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return fmt.Errorf("create renderer: %w", err) | |||
| 120 | + | } | |||
| 121 | + | defer r.Close() | |||
| 122 | + | if err := o.modeset(); err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | events := make(chan int, 4) | |||
| 126 | + | evErr := make(chan error, 1) | |||
| 127 | + | go o.readEvents(ctx, events, evErr) | |||
| 128 | + | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |||
| 129 | + | var scene ports.Scene | |||
| 130 | + | haveScene, dirty, enabled := false, false, true | |||
| 131 | + | frame := 0 | |||
| 132 | + | stats := time.NewTicker(10 * time.Second) | |||
| 133 | + | defer stats.Stop() | |||
| 134 | + | for { | |||
| 135 | + | select { | |||
| 136 | + | case <-ctx.Done(): | |||
| 137 | + | return nil | |||
| 138 | + | case err := <-evErr: | |||
| 139 | + | return err | |||
| 140 | + | case on := <-active: | |||
| 141 | + | if on && !enabled { | |||
| 142 | + | if err := o.modeset(); err != nil { | |||
| 143 | + | o.log.Error().Err(err).Msg("resume") | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | dirty = haveScene | |||
| 147 | + | } | |||
| 148 | + | enabled = on | |||
| 149 | + | o.log.Info().Bool("enabled", on).Msg("output") | |||
| 150 | + | case n := <-events: | |||
| 151 | + | o.pending = false | |||
| 152 | + | o.flips += n | |||
| 153 | + | if d := time.Since(o.flipStart); d > 20*time.Millisecond { | |||
| 154 | + | o.log.Info().Dur("flip_ms", d).Msg("slow flip") | |||
| 155 | + | } | |||
| 156 | + | case s := <-scenes: | |||
| 157 | + | scene, haveScene, dirty = s, true, true | |||
| 158 | + | case c := <-contents: | |||
| 159 | + | if c.Pixels == nil { | |||
| 160 | + | delete(surfaces, c.ID) | |||
| 161 | + | } else { | |||
| 162 | + | surfaces[c.ID] = c | |||
| 163 | + | } | |||
| 164 | + | dirty = true | |||
| 165 | + | case <-stats.C: | |||
| 166 | + | o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats") | |||
| 167 | + | } | |||
| 168 | + | if !dirty || !haveScene || !enabled || o.pending { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | start := time.Now() | |||
| 172 | + | if err := r.Render(scene, surfaces); err != nil { | |||
| 173 | + | return fmt.Errorf("render frame: %w", err) | |||
| 174 | + | } | |||
| 175 | + | if err := o.present(r.Pixels()); err != nil { | |||
| 176 | + | if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) { | |||
| 177 | + | // Lost DRM master mid-switch; the seat disable follows. | |||
| 178 | + | o.log.Warn().Err(err).Msg("flip refused") | |||
| 179 | + | enabled = false | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | o.log.Error().Err(err).Msg("page flip") | |||
| 183 | + | return fmt.Errorf("page flip: %w", err) | |||
| 184 | + | } | |||
| 185 | + | dirty = false | |||
| 186 | + | frame++ | |||
| 187 | + | o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Msg("frame") | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) { | |||
| 192 | + | buf := make([]byte, 1024) | |||
| 193 | + | for ctx.Err() == nil { | |||
| 194 | + | fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}} | |||
| 195 | + | n, err := unix.Poll(fds, 100) | |||
| 196 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |||
| 197 | + | errs <- fmt.Errorf("drm poll: %w", err) | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | if n <= 0 { | |||
| 201 | + | continue | |||
| 202 | + | } | |||
| 203 | + | m, err := unix.Read(o.fd, buf) | |||
| 204 | + | if err != nil { | |||
| 205 | + | if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) { | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | errs <- fmt.Errorf("drm read: %w", err) | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | if c := countFlipEvents(buf[:m]); c > 0 { | |||
| 212 | + | select { | |||
| 213 | + | case events <- c: | |||
| 214 | + | case <-ctx.Done(): | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | } | |||
A internal/adapters/libinput/libinput.go +295 -0
| @@ -0,0 +1,295 @@ | |||
| 1 | + | // Package libinput reads keyboards and pointers through libinput's udev backend. | |
| 2 | + | package libinput | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | "unsafe" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 14 | + | "github.com/bnema/nefertty/internal/ports" | |
| 15 | + | "github.com/bnema/purego" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed. | |
| 21 | + | var ErrEmergencyQuit = errors.New("emergency quit key") | |
| 22 | + | ||
| 23 | + | // Seat is the device broker libinput opens devices through. | |
| 24 | + | type Seat interface { | |
| 25 | + | OpenDevice(path string) (int, error) | |
| 26 | + | CloseDevice(fd int) | |
| 27 | + | SwitchVT(vt int) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | const ( | |
| 31 | + | evDeviceAdded = 1 | |
| 32 | + | evDeviceRemoved = 2 | |
| 33 | + | evKeyboardKey = 300 | |
| 34 | + | evPointerMotion = 400 | |
| 35 | + | evPointerAbs = 401 | |
| 36 | + | evPointerButton = 402 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | var ( | |
| 40 | + | loadOnce sync.Once | |
| 41 | + | loadErr error | |
| 42 | + | ||
| 43 | + | udevNew func() uintptr | |
| 44 | + | udevUnref func(uintptr) uintptr | |
| 45 | + | createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr | |
| 46 | + | assignSeat func(li uintptr, seat string) int32 | |
| 47 | + | unref func(li uintptr) uintptr | |
| 48 | + | getFD func(li uintptr) int32 | |
| 49 | + | dispatch func(li uintptr) int32 | |
| 50 | + | getEvent func(li uintptr) uintptr | |
| 51 | + | eventType func(ev uintptr) int32 | |
| 52 | + | eventDestroy func(ev uintptr) | |
| 53 | + | eventDevice func(ev uintptr) uintptr | |
| 54 | + | deviceName func(dev uintptr) string | |
| 55 | + | suspend func(li uintptr) | |
| 56 | + | resume func(li uintptr) int32 | |
| 57 | + | keyboardEvent func(ev uintptr) uintptr | |
| 58 | + | keyboardKey func(kev uintptr) uint32 | |
| 59 | + | keyboardState func(kev uintptr) int32 | |
| 60 | + | pointerEvent func(ev uintptr) uintptr | |
| 61 | + | pointerDX func(pev uintptr) float64 | |
| 62 | + | pointerDY func(pev uintptr) float64 | |
| 63 | + | pointerAbsX func(pev uintptr, width uint32) float64 | |
| 64 | + | pointerAbsY func(pev uintptr, height uint32) float64 | |
| 65 | + | pointerButton func(pev uintptr) uint32 | |
| 66 | + | pointerBtnState func(pev uintptr) int32 | |
| 67 | + | iface [2]uintptr | |
| 68 | + | active Seat | |
| 69 | + | ) | |
| 70 | + | ||
| 71 | + | func load() error { | |
| 72 | + | loadOnce.Do(func() { | |
| 73 | + | udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL) | |
| 74 | + | if err != nil { | |
| 75 | + | loadErr = fmt.Errorf("open libudev.so.1: %w", err) | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL) | |
| 79 | + | if err != nil { | |
| 80 | + | loadErr = fmt.Errorf("open libinput.so.10: %w", err) | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | defer func() { | |
| 84 | + | if r := recover(); r != nil { | |
| 85 | + | loadErr = fmt.Errorf("libinput: %v", r) | |
| 86 | + | } | |
| 87 | + | }() | |
| 88 | + | purego.RegisterLibFunc(&udevNew, udev, "udev_new") | |
| 89 | + | purego.RegisterLibFunc(&udevUnref, udev, "udev_unref") | |
| 90 | + | reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) } | |
| 91 | + | reg(&createContext, "udev_create_context") | |
| 92 | + | reg(&assignSeat, "udev_assign_seat") | |
| 93 | + | reg(&unref, "unref") | |
| 94 | + | reg(&getFD, "get_fd") | |
| 95 | + | reg(&dispatch, "dispatch") | |
| 96 | + | reg(&getEvent, "get_event") | |
| 97 | + | reg(&eventType, "event_get_type") | |
| 98 | + | reg(&eventDestroy, "event_destroy") | |
| 99 | + | reg(&eventDevice, "event_get_device") | |
| 100 | + | reg(&deviceName, "device_get_name") | |
| 101 | + | reg(&suspend, "suspend") | |
| 102 | + | reg(&resume, "resume") | |
| 103 | + | reg(&keyboardEvent, "event_get_keyboard_event") | |
| 104 | + | reg(&keyboardKey, "event_keyboard_get_key") | |
| 105 | + | reg(&keyboardState, "event_keyboard_get_key_state") | |
| 106 | + | reg(&pointerEvent, "event_get_pointer_event") | |
| 107 | + | reg(&pointerDX, "event_pointer_get_dx") | |
| 108 | + | reg(&pointerDY, "event_pointer_get_dy") | |
| 109 | + | reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed") | |
| 110 | + | reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed") | |
| 111 | + | reg(&pointerButton, "event_pointer_get_button") | |
| 112 | + | reg(&pointerBtnState, "event_pointer_get_button_state") | |
| 113 | + | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | |
| 114 | + | fd, err := active.OpenDevice(goString(path)) | |
| 115 | + | if err != nil { | |
| 116 | + | return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int | |
| 117 | + | } | |
| 118 | + | return uintptr(fd) | |
| 119 | + | }) | |
| 120 | + | iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) }) | |
| 121 | + | }) | |
| 122 | + | return loadErr | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func goString(p *byte) string { | |
| 126 | + | if p == nil { | |
| 127 | + | return "" | |
| 128 | + | } | |
| 129 | + | n := 0 | |
| 130 | + | for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 { | |
| 131 | + | n++ | |
| 132 | + | } | |
| 133 | + | return string(unsafe.Slice(p, n)) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Options configures the input adapter. | |
| 137 | + | type Options struct { | |
| 138 | + | Seat Seat | |
| 139 | + | SeatName string | |
| 140 | + | Keymap *xkb.Keymap | |
| 141 | + | Width, Height int | |
| 142 | + | Active <-chan bool | |
| 143 | + | Log zerowrap.Logger | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Run owns libinput and the keymap, converting device events into input events. | |
| 147 | + | func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error { | |
| 148 | + | defer opts.Keymap.Close() | |
| 149 | + | if err := load(); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | active = opts.Seat | |
| 153 | + | udev := udevNew() | |
| 154 | + | if udev == 0 { | |
| 155 | + | return errors.New("udev_new failed") | |
| 156 | + | } | |
| 157 | + | defer udevUnref(udev) | |
| 158 | + | li := createContext(unsafe.Pointer(&iface), 0, udev) | |
| 159 | + | if li == 0 { | |
| 160 | + | return errors.New("libinput_udev_create_context failed") | |
| 161 | + | } | |
| 162 | + | defer unref(li) | |
| 163 | + | name := opts.SeatName | |
| 164 | + | if name == "" { | |
| 165 | + | name = "seat0" | |
| 166 | + | } | |
| 167 | + | if assignSeat(li, name) != 0 { | |
| 168 | + | return fmt.Errorf("libinput_udev_assign_seat %s failed", name) | |
| 169 | + | } | |
| 170 | + | p := pointer{w: float64(opts.Width), h: float64(opts.Height)} | |
| 171 | + | p.x, p.y = p.w/2, p.h/2 | |
| 172 | + | fd := getFD(li) | |
| 173 | + | for ctx.Err() == nil { | |
| 174 | + | select { | |
| 175 | + | case on := <-opts.Active: | |
| 176 | + | if on { | |
| 177 | + | resume(li) | |
| 178 | + | } else { | |
| 179 | + | suspend(li) | |
| 180 | + | } | |
| 181 | + | opts.Log.Info().Bool("active", on).Msg("input") | |
| 182 | + | default: | |
| 183 | + | } | |
| 184 | + | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | |
| 185 | + | n, err := unix.Poll(fds, 100) | |
| 186 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |
| 187 | + | return fmt.Errorf("libinput poll: %w", err) | |
| 188 | + | } | |
| 189 | + | if n <= 0 { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | if dispatch(li) != 0 { | |
| 193 | + | opts.Log.Warn().Msg("libinput_dispatch failed") | |
| 194 | + | } | |
| 195 | + | for ev := getEvent(li); ev != 0; ev = getEvent(li) { | |
| 196 | + | out, err := translate(ev, opts, &p) | |
| 197 | + | eventDestroy(ev) | |
| 198 | + | if err != nil { | |
| 199 | + | return err | |
| 200 | + | } | |
| 201 | + | if out == nil { | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | select { | |
| 205 | + | case input <- out: | |
| 206 | + | case <-ctx.Done(): | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |
| 215 | + | now := uint32(time.Now().UnixMilli()) | |
| 216 | + | log := opts.Log | |
| 217 | + | switch eventType(ev) { | |
| 218 | + | case evDeviceAdded: | |
| 219 | + | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added") | |
| 220 | + | case evDeviceRemoved: | |
| 221 | + | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed") | |
| 222 | + | case evKeyboardKey: | |
| 223 | + | k := keyboardEvent(ev) | |
| 224 | + | code, pressed := keyboardKey(k), keyboardState(k) == 1 | |
| 225 | + | ke := opts.Keymap.Key(code, pressed, now) | |
| 226 | + | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | |
| 227 | + | switch action, vt := hotkey(ke); action { | |
| 228 | + | case hotkeyQuit: | |
| 229 | + | log.Warn().Str("reason", "emergency-key").Msg("quit") | |
| 230 | + | return nil, ErrEmergencyQuit | |
| 231 | + | case hotkeyVT: | |
| 232 | + | opts.Seat.SwitchVT(vt) | |
| 233 | + | return nil, nil | |
| 234 | + | } | |
| 235 | + | return ke, nil | |
| 236 | + | case evPointerMotion: | |
| 237 | + | pe := pointerEvent(ev) | |
| 238 | + | x, y := p.move(pointerDX(pe), pointerDY(pe)) | |
| 239 | + | log.Debug().Float64("x", x).Float64("y", y).Msg("pointer") | |
| 240 | + | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 241 | + | case evPointerAbs: | |
| 242 | + | pe := pointerEvent(ev) | |
| 243 | + | x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h))) | |
| 244 | + | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 245 | + | case evPointerButton: | |
| 246 | + | pe := pointerEvent(ev) | |
| 247 | + | b, pressed := pointerButton(pe), pointerBtnState(pe) == 1 | |
| 248 | + | log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button") | |
| 249 | + | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil | |
| 250 | + | } | |
| 251 | + | return nil, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | type hotkeyAction int | |
| 255 | + | ||
| 256 | + | const ( | |
| 257 | + | hotkeyNone hotkeyAction = iota | |
| 258 | + | hotkeyQuit | |
| 259 | + | hotkeyVT | |
| 260 | + | ) | |
| 261 | + | ||
| 262 | + | // hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12. | |
| 263 | + | func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |
| 264 | + | if !ke.Pressed { | |
| 265 | + | return hotkeyNone, 0 | |
| 266 | + | } | |
| 267 | + | if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok { | |
| 268 | + | var n int | |
| 269 | + | if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 { | |
| 270 | + | return hotkeyVT, n | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt { | |
| 274 | + | return hotkeyNone, 0 | |
| 275 | + | } | |
| 276 | + | switch { | |
| 277 | + | case ke.Keysym == "BackSpace" || ke.Keycode == 14: | |
| 278 | + | return hotkeyQuit, 0 | |
| 279 | + | case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10 | |
| 280 | + | return hotkeyVT, int(ke.Keycode) - 58 | |
| 281 | + | case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12 | |
| 282 | + | return hotkeyVT, int(ke.Keycode) - 76 | |
| 283 | + | } | |
| 284 | + | return hotkeyNone, 0 | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | type pointer struct{ x, y, w, h float64 } | |
| 288 | + | ||
| 289 | + | func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) } | |
| 290 | + | ||
| 291 | + | func (p *pointer) set(x, y float64) (float64, float64) { | |
| 292 | + | p.x = min(max(x, 0), p.w-1) | |
| 293 | + | p.y = min(max(y, 0), p.h-1) | |
| 294 | + | return p.x, p.y | |
| 295 | + | } | |
| @@ -0,0 +1,295 @@ | |||||
| 1 | + | // Package libinput reads keyboards and pointers through libinput's udev backend. | |||
| 2 | + | package libinput | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | "unsafe" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |||
| 14 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 15 | + | "github.com/bnema/purego" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | "golang.org/x/sys/unix" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed. | |||
| 21 | + | var ErrEmergencyQuit = errors.New("emergency quit key") | |||
| 22 | + | ||||
| 23 | + | // Seat is the device broker libinput opens devices through. | |||
| 24 | + | type Seat interface { | |||
| 25 | + | OpenDevice(path string) (int, error) | |||
| 26 | + | CloseDevice(fd int) | |||
| 27 | + | SwitchVT(vt int) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | const ( | |||
| 31 | + | evDeviceAdded = 1 | |||
| 32 | + | evDeviceRemoved = 2 | |||
| 33 | + | evKeyboardKey = 300 | |||
| 34 | + | evPointerMotion = 400 | |||
| 35 | + | evPointerAbs = 401 | |||
| 36 | + | evPointerButton = 402 | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | var ( | |||
| 40 | + | loadOnce sync.Once | |||
| 41 | + | loadErr error | |||
| 42 | + | ||||
| 43 | + | udevNew func() uintptr | |||
| 44 | + | udevUnref func(uintptr) uintptr | |||
| 45 | + | createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr | |||
| 46 | + | assignSeat func(li uintptr, seat string) int32 | |||
| 47 | + | unref func(li uintptr) uintptr | |||
| 48 | + | getFD func(li uintptr) int32 | |||
| 49 | + | dispatch func(li uintptr) int32 | |||
| 50 | + | getEvent func(li uintptr) uintptr | |||
| 51 | + | eventType func(ev uintptr) int32 | |||
| 52 | + | eventDestroy func(ev uintptr) | |||
| 53 | + | eventDevice func(ev uintptr) uintptr | |||
| 54 | + | deviceName func(dev uintptr) string | |||
| 55 | + | suspend func(li uintptr) | |||
| 56 | + | resume func(li uintptr) int32 | |||
| 57 | + | keyboardEvent func(ev uintptr) uintptr | |||
| 58 | + | keyboardKey func(kev uintptr) uint32 | |||
| 59 | + | keyboardState func(kev uintptr) int32 | |||
| 60 | + | pointerEvent func(ev uintptr) uintptr | |||
| 61 | + | pointerDX func(pev uintptr) float64 | |||
| 62 | + | pointerDY func(pev uintptr) float64 | |||
| 63 | + | pointerAbsX func(pev uintptr, width uint32) float64 | |||
| 64 | + | pointerAbsY func(pev uintptr, height uint32) float64 | |||
| 65 | + | pointerButton func(pev uintptr) uint32 | |||
| 66 | + | pointerBtnState func(pev uintptr) int32 | |||
| 67 | + | iface [2]uintptr | |||
| 68 | + | active Seat | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 71 | + | func load() error { | |||
| 72 | + | loadOnce.Do(func() { | |||
| 73 | + | udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL) | |||
| 74 | + | if err != nil { | |||
| 75 | + | loadErr = fmt.Errorf("open libudev.so.1: %w", err) | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL) | |||
| 79 | + | if err != nil { | |||
| 80 | + | loadErr = fmt.Errorf("open libinput.so.10: %w", err) | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | defer func() { | |||
| 84 | + | if r := recover(); r != nil { | |||
| 85 | + | loadErr = fmt.Errorf("libinput: %v", r) | |||
| 86 | + | } | |||
| 87 | + | }() | |||
| 88 | + | purego.RegisterLibFunc(&udevNew, udev, "udev_new") | |||
| 89 | + | purego.RegisterLibFunc(&udevUnref, udev, "udev_unref") | |||
| 90 | + | reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) } | |||
| 91 | + | reg(&createContext, "udev_create_context") | |||
| 92 | + | reg(&assignSeat, "udev_assign_seat") | |||
| 93 | + | reg(&unref, "unref") | |||
| 94 | + | reg(&getFD, "get_fd") | |||
| 95 | + | reg(&dispatch, "dispatch") | |||
| 96 | + | reg(&getEvent, "get_event") | |||
| 97 | + | reg(&eventType, "event_get_type") | |||
| 98 | + | reg(&eventDestroy, "event_destroy") | |||
| 99 | + | reg(&eventDevice, "event_get_device") | |||
| 100 | + | reg(&deviceName, "device_get_name") | |||
| 101 | + | reg(&suspend, "suspend") | |||
| 102 | + | reg(&resume, "resume") | |||
| 103 | + | reg(&keyboardEvent, "event_get_keyboard_event") | |||
| 104 | + | reg(&keyboardKey, "event_keyboard_get_key") | |||
| 105 | + | reg(&keyboardState, "event_keyboard_get_key_state") | |||
| 106 | + | reg(&pointerEvent, "event_get_pointer_event") | |||
| 107 | + | reg(&pointerDX, "event_pointer_get_dx") | |||
| 108 | + | reg(&pointerDY, "event_pointer_get_dy") | |||
| 109 | + | reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed") | |||
| 110 | + | reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed") | |||
| 111 | + | reg(&pointerButton, "event_pointer_get_button") | |||
| 112 | + | reg(&pointerBtnState, "event_pointer_get_button_state") | |||
| 113 | + | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | |||
| 114 | + | fd, err := active.OpenDevice(goString(path)) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int | |||
| 117 | + | } | |||
| 118 | + | return uintptr(fd) | |||
| 119 | + | }) | |||
| 120 | + | iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) }) | |||
| 121 | + | }) | |||
| 122 | + | return loadErr | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func goString(p *byte) string { | |||
| 126 | + | if p == nil { | |||
| 127 | + | return "" | |||
| 128 | + | } | |||
| 129 | + | n := 0 | |||
| 130 | + | for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 { | |||
| 131 | + | n++ | |||
| 132 | + | } | |||
| 133 | + | return string(unsafe.Slice(p, n)) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Options configures the input adapter. | |||
| 137 | + | type Options struct { | |||
| 138 | + | Seat Seat | |||
| 139 | + | SeatName string | |||
| 140 | + | Keymap *xkb.Keymap | |||
| 141 | + | Width, Height int | |||
| 142 | + | Active <-chan bool | |||
| 143 | + | Log zerowrap.Logger | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Run owns libinput and the keymap, converting device events into input events. | |||
| 147 | + | func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error { | |||
| 148 | + | defer opts.Keymap.Close() | |||
| 149 | + | if err := load(); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | active = opts.Seat | |||
| 153 | + | udev := udevNew() | |||
| 154 | + | if udev == 0 { | |||
| 155 | + | return errors.New("udev_new failed") | |||
| 156 | + | } | |||
| 157 | + | defer udevUnref(udev) | |||
| 158 | + | li := createContext(unsafe.Pointer(&iface), 0, udev) | |||
| 159 | + | if li == 0 { | |||
| 160 | + | return errors.New("libinput_udev_create_context failed") | |||
| 161 | + | } | |||
| 162 | + | defer unref(li) | |||
| 163 | + | name := opts.SeatName | |||
| 164 | + | if name == "" { | |||
| 165 | + | name = "seat0" | |||
| 166 | + | } | |||
| 167 | + | if assignSeat(li, name) != 0 { | |||
| 168 | + | return fmt.Errorf("libinput_udev_assign_seat %s failed", name) | |||
| 169 | + | } | |||
| 170 | + | p := pointer{w: float64(opts.Width), h: float64(opts.Height)} | |||
| 171 | + | p.x, p.y = p.w/2, p.h/2 | |||
| 172 | + | fd := getFD(li) | |||
| 173 | + | for ctx.Err() == nil { | |||
| 174 | + | select { | |||
| 175 | + | case on := <-opts.Active: | |||
| 176 | + | if on { | |||
| 177 | + | resume(li) | |||
| 178 | + | } else { | |||
| 179 | + | suspend(li) | |||
| 180 | + | } | |||
| 181 | + | opts.Log.Info().Bool("active", on).Msg("input") | |||
| 182 | + | default: | |||
| 183 | + | } | |||
| 184 | + | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | |||
| 185 | + | n, err := unix.Poll(fds, 100) | |||
| 186 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |||
| 187 | + | return fmt.Errorf("libinput poll: %w", err) | |||
| 188 | + | } | |||
| 189 | + | if n <= 0 { | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | if dispatch(li) != 0 { | |||
| 193 | + | opts.Log.Warn().Msg("libinput_dispatch failed") | |||
| 194 | + | } | |||
| 195 | + | for ev := getEvent(li); ev != 0; ev = getEvent(li) { | |||
| 196 | + | out, err := translate(ev, opts, &p) | |||
| 197 | + | eventDestroy(ev) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return err | |||
| 200 | + | } | |||
| 201 | + | if out == nil { | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | select { | |||
| 205 | + | case input <- out: | |||
| 206 | + | case <-ctx.Done(): | |||
| 207 | + | return nil | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |||
| 215 | + | now := uint32(time.Now().UnixMilli()) | |||
| 216 | + | log := opts.Log | |||
| 217 | + | switch eventType(ev) { | |||
| 218 | + | case evDeviceAdded: | |||
| 219 | + | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added") | |||
| 220 | + | case evDeviceRemoved: | |||
| 221 | + | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed") | |||
| 222 | + | case evKeyboardKey: | |||
| 223 | + | k := keyboardEvent(ev) | |||
| 224 | + | code, pressed := keyboardKey(k), keyboardState(k) == 1 | |||
| 225 | + | ke := opts.Keymap.Key(code, pressed, now) | |||
| 226 | + | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | |||
| 227 | + | switch action, vt := hotkey(ke); action { | |||
| 228 | + | case hotkeyQuit: | |||
| 229 | + | log.Warn().Str("reason", "emergency-key").Msg("quit") | |||
| 230 | + | return nil, ErrEmergencyQuit | |||
| 231 | + | case hotkeyVT: | |||
| 232 | + | opts.Seat.SwitchVT(vt) | |||
| 233 | + | return nil, nil | |||
| 234 | + | } | |||
| 235 | + | return ke, nil | |||
| 236 | + | case evPointerMotion: | |||
| 237 | + | pe := pointerEvent(ev) | |||
| 238 | + | x, y := p.move(pointerDX(pe), pointerDY(pe)) | |||
| 239 | + | log.Debug().Float64("x", x).Float64("y", y).Msg("pointer") | |||
| 240 | + | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |||
| 241 | + | case evPointerAbs: | |||
| 242 | + | pe := pointerEvent(ev) | |||
| 243 | + | x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h))) | |||
| 244 | + | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |||
| 245 | + | case evPointerButton: | |||
| 246 | + | pe := pointerEvent(ev) | |||
| 247 | + | b, pressed := pointerButton(pe), pointerBtnState(pe) == 1 | |||
| 248 | + | log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button") | |||
| 249 | + | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil | |||
| 250 | + | } | |||
| 251 | + | return nil, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | type hotkeyAction int | |||
| 255 | + | ||||
| 256 | + | const ( | |||
| 257 | + | hotkeyNone hotkeyAction = iota | |||
| 258 | + | hotkeyQuit | |||
| 259 | + | hotkeyVT | |||
| 260 | + | ) | |||
| 261 | + | ||||
| 262 | + | // hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12. | |||
| 263 | + | func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |||
| 264 | + | if !ke.Pressed { | |||
| 265 | + | return hotkeyNone, 0 | |||
| 266 | + | } | |||
| 267 | + | if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok { | |||
| 268 | + | var n int | |||
| 269 | + | if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 { | |||
| 270 | + | return hotkeyVT, n | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt { | |||
| 274 | + | return hotkeyNone, 0 | |||
| 275 | + | } | |||
| 276 | + | switch { | |||
| 277 | + | case ke.Keysym == "BackSpace" || ke.Keycode == 14: | |||
| 278 | + | return hotkeyQuit, 0 | |||
| 279 | + | case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10 | |||
| 280 | + | return hotkeyVT, int(ke.Keycode) - 58 | |||
| 281 | + | case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12 | |||
| 282 | + | return hotkeyVT, int(ke.Keycode) - 76 | |||
| 283 | + | } | |||
| 284 | + | return hotkeyNone, 0 | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | type pointer struct{ x, y, w, h float64 } | |||
| 288 | + | ||||
| 289 | + | func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) } | |||
| 290 | + | ||||
| 291 | + | func (p *pointer) set(x, y float64) (float64, float64) { | |||
| 292 | + | p.x = min(max(x, 0), p.w-1) | |||
| 293 | + | p.y = min(max(y, 0), p.h-1) | |||
| 294 | + | return p.x, p.y | |||
| 295 | + | } | |||
A internal/adapters/libinput/libinput_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package libinput | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestHotkey(t *testing.T) { | |
| 10 | + | ca := ports.ModCtrl | ports.ModAlt | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | name string | |
| 13 | + | ev ports.KeyEvent | |
| 14 | + | action hotkeyAction | |
| 15 | + | vt int | |
| 16 | + | }{ | |
| 17 | + | {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0}, | |
| 18 | + | {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0}, | |
| 19 | + | {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0}, | |
| 20 | + | {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3}, | |
| 21 | + | {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2}, | |
| 22 | + | {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12}, | |
| 23 | + | {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0}, | |
| 24 | + | } { | |
| 25 | + | a, vt := hotkey(tc.ev) | |
| 26 | + | if a != tc.action || vt != tc.vt { | |
| 27 | + | t.Errorf("%s: got %v %d", tc.name, a, vt) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestPointerClamp(t *testing.T) { | |
| 33 | + | p := pointer{x: 10, y: 10, w: 100, h: 50} | |
| 34 | + | if x, y := p.move(-20, 100); x != 0 || y != 49 { | |
| 35 | + | t.Fatalf("got %v %v", x, y) | |
| 36 | + | } | |
| 37 | + | if x, y := p.move(30.5, -5); x != 30.5 || y != 44 { | |
| 38 | + | t.Fatalf("got %v %v", x, y) | |
| 39 | + | } | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package libinput | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestHotkey(t *testing.T) { | |||
| 10 | + | ca := ports.ModCtrl | ports.ModAlt | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | ev ports.KeyEvent | |||
| 14 | + | action hotkeyAction | |||
| 15 | + | vt int | |||
| 16 | + | }{ | |||
| 17 | + | {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0}, | |||
| 18 | + | {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0}, | |||
| 19 | + | {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0}, | |||
| 20 | + | {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3}, | |||
| 21 | + | {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2}, | |||
| 22 | + | {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12}, | |||
| 23 | + | {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0}, | |||
| 24 | + | } { | |||
| 25 | + | a, vt := hotkey(tc.ev) | |||
| 26 | + | if a != tc.action || vt != tc.vt { | |||
| 27 | + | t.Errorf("%s: got %v %d", tc.name, a, vt) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestPointerClamp(t *testing.T) { | |||
| 33 | + | p := pointer{x: 10, y: 10, w: 100, h: 50} | |||
| 34 | + | if x, y := p.move(-20, 100); x != 0 || y != 49 { | |||
| 35 | + | t.Fatalf("got %v %v", x, y) | |||
| 36 | + | } | |||
| 37 | + | if x, y := p.move(30.5, -5); x != 30.5 || y != 44 { | |||
| 38 | + | t.Fatalf("got %v %v", x, y) | |||
| 39 | + | } | |||
| 40 | + | } | |||
A internal/adapters/seat/seat.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | // Package seat opens a libseat session (logind or seatd) and hands out device fds. | |
| 2 | + | package seat | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "runtime" | |
| 9 | + | "sync" | |
| 10 | + | "unsafe" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/purego" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | loadOnce sync.Once | |
| 19 | + | loadErr error | |
| 20 | + | ||
| 21 | + | openSeat func(listener unsafe.Pointer, data uintptr) uintptr | |
| 22 | + | closeSeat func(seat uintptr) int32 | |
| 23 | + | disableSeat func(seat uintptr) int32 | |
| 24 | + | openDevice func(seat uintptr, path string, fd *int32) int32 | |
| 25 | + | closeDevice func(seat uintptr, id int32) int32 | |
| 26 | + | seatName func(seat uintptr) string | |
| 27 | + | switchSession func(seat uintptr, session int32) int32 | |
| 28 | + | getFD func(seat uintptr) int32 | |
| 29 | + | dispatch func(seat uintptr, timeout int32) int32 | |
| 30 | + | ||
| 31 | + | listener [2]uintptr | |
| 32 | + | // libseat allows one seat per process; callbacks find it here. | |
| 33 | + | current *Seat | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | func load() error { | |
| 37 | + | loadOnce.Do(func() { | |
| 38 | + | lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL) | |
| 39 | + | if err != nil { | |
| 40 | + | loadErr = fmt.Errorf("open libseat.so.1: %w", err) | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | defer func() { | |
| 44 | + | if r := recover(); r != nil { | |
| 45 | + | loadErr = fmt.Errorf("libseat: %v", r) | |
| 46 | + | } | |
| 47 | + | }() | |
| 48 | + | reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) } | |
| 49 | + | reg(&openSeat, "open_seat") | |
| 50 | + | reg(&closeSeat, "close_seat") | |
| 51 | + | reg(&disableSeat, "disable_seat") | |
| 52 | + | reg(&openDevice, "open_device") | |
| 53 | + | reg(&closeDevice, "close_device") | |
| 54 | + | reg(&seatName, "seat_name") | |
| 55 | + | reg(&switchSession, "switch_session") | |
| 56 | + | reg(&getFD, "get_fd") | |
| 57 | + | reg(&dispatch, "dispatch") | |
| 58 | + | listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) }) | |
| 59 | + | listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) }) | |
| 60 | + | }) | |
| 61 | + | return loadErr | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Seat owns the libseat handle. libseat is not thread-safe, so every call goes | |
| 65 | + | // through mu; callbacks only run inside dispatch, which already holds it. | |
| 66 | + | type Seat struct { | |
| 67 | + | mu sync.Mutex | |
| 68 | + | handle uintptr | |
| 69 | + | active bool | |
| 70 | + | devices map[int32]int32 // fd → libseat device id | |
| 71 | + | subs []chan bool | |
| 72 | + | log zerowrap.Logger | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Open connects to logind or seatd and waits for the first enable. | |
| 76 | + | func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) { | |
| 77 | + | if err := load(); err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | if current != nil { | |
| 81 | + | return nil, errors.New("seat already open") | |
| 82 | + | } | |
| 83 | + | s := &Seat{devices: make(map[int32]int32), log: log} | |
| 84 | + | current = s | |
| 85 | + | s.mu.Lock() | |
| 86 | + | defer s.mu.Unlock() | |
| 87 | + | s.handle = openSeat(unsafe.Pointer(&listener), 0) | |
| 88 | + | runtime.KeepAlive(&listener) | |
| 89 | + | if s.handle == 0 { | |
| 90 | + | current = nil | |
| 91 | + | return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)") | |
| 92 | + | } | |
| 93 | + | log.Info().Str("seat", seatName(s.handle)).Msg("seat opened") | |
| 94 | + | for !s.active { | |
| 95 | + | if err := s.dispatchLocked(ctx, 100); err != nil { | |
| 96 | + | s.closeLocked() | |
| 97 | + | return nil, fmt.Errorf("waiting for seat enable: %w", err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return s, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (s *Seat) changed(active bool) { | |
| 104 | + | s.active = active | |
| 105 | + | s.log.Info().Bool("active", active).Msg("seat") | |
| 106 | + | if !active { | |
| 107 | + | // Devices are revoked by the seat manager; ack so the VT can switch. | |
| 108 | + | disableSeat(s.handle) | |
| 109 | + | } | |
| 110 | + | for _, c := range s.subs { | |
| 111 | + | select { | |
| 112 | + | case c <- active: | |
| 113 | + | default: | |
| 114 | + | select { | |
| 115 | + | case <-c: | |
| 116 | + | default: | |
| 117 | + | } | |
| 118 | + | c <- active | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Name returns the seat name, e.g. seat0. | |
| 124 | + | func (s *Seat) Name() string { | |
| 125 | + | s.mu.Lock() | |
| 126 | + | defer s.mu.Unlock() | |
| 127 | + | return seatName(s.handle) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Subscribe returns a channel that receives the seat state after every enable/disable. | |
| 131 | + | func (s *Seat) Subscribe() <-chan bool { | |
| 132 | + | c := make(chan bool, 1) | |
| 133 | + | s.mu.Lock() | |
| 134 | + | s.subs = append(s.subs, c) | |
| 135 | + | s.mu.Unlock() | |
| 136 | + | return c | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error { | |
| 140 | + | if ctx.Err() != nil { | |
| 141 | + | return ctx.Err() | |
| 142 | + | } | |
| 143 | + | fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}} | |
| 144 | + | if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | if dispatch(s.handle, 0) < 0 { | |
| 148 | + | return errors.New("libseat_dispatch failed") | |
| 149 | + | } | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Run dispatches seat events until ctx ends. The owner calls Close afterwards, | |
| 154 | + | // once outputs have restored their CRTC state. | |
| 155 | + | func (s *Seat) Run(ctx context.Context) error { | |
| 156 | + | for ctx.Err() == nil { | |
| 157 | + | fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}} | |
| 158 | + | n, err := unix.Poll(fds, 100) | |
| 159 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |
| 160 | + | return fmt.Errorf("seat poll: %w", err) | |
| 161 | + | } | |
| 162 | + | if n <= 0 { | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | s.mu.Lock() | |
| 166 | + | r := dispatch(s.handle, 0) | |
| 167 | + | s.mu.Unlock() | |
| 168 | + | if r < 0 { | |
| 169 | + | return errors.New("libseat_dispatch failed") | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return nil | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // OpenDevice opens path through the seat manager and returns its fd. | |
| 176 | + | func (s *Seat) OpenDevice(path string) (int, error) { | |
| 177 | + | s.mu.Lock() | |
| 178 | + | defer s.mu.Unlock() | |
| 179 | + | var fd int32 = -1 | |
| 180 | + | id := openDevice(s.handle, path, &fd) | |
| 181 | + | if id < 0 { | |
| 182 | + | s.log.Warn().Str("path", path).Msg("open device failed") | |
| 183 | + | return -1, fmt.Errorf("libseat_open_device %s failed", path) | |
| 184 | + | } | |
| 185 | + | s.devices[fd] = id | |
| 186 | + | s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened") | |
| 187 | + | return int(fd), nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // CloseDevice releases a fd returned by OpenDevice. | |
| 191 | + | func (s *Seat) CloseDevice(fd int) { | |
| 192 | + | s.mu.Lock() | |
| 193 | + | defer s.mu.Unlock() | |
| 194 | + | id, ok := s.devices[int32(fd)] | |
| 195 | + | if !ok { | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | delete(s.devices, int32(fd)) | |
| 199 | + | closeDevice(s.handle, id) | |
| 200 | + | _ = unix.Close(fd) | |
| 201 | + | s.log.Info().Int("fd", fd).Msg("device closed") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // SwitchVT asks the seat manager to switch to virtual terminal vt. | |
| 205 | + | func (s *Seat) SwitchVT(vt int) { | |
| 206 | + | s.mu.Lock() | |
| 207 | + | defer s.mu.Unlock() | |
| 208 | + | s.log.Info().Int("vt", vt).Msg("switch vt") | |
| 209 | + | if switchSession(s.handle, int32(vt)) < 0 { | |
| 210 | + | s.log.Warn().Int("vt", vt).Msg("switch vt failed") | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // Close releases all devices and the seat; the VT returns to text mode. Safe to call twice. | |
| 215 | + | func (s *Seat) Close() { | |
| 216 | + | s.mu.Lock() | |
| 217 | + | defer s.mu.Unlock() | |
| 218 | + | s.closeLocked() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (s *Seat) closeLocked() { | |
| 222 | + | if s.handle == 0 { | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | for fd, id := range s.devices { | |
| 226 | + | closeDevice(s.handle, id) | |
| 227 | + | _ = unix.Close(int(fd)) | |
| 228 | + | } | |
| 229 | + | s.devices = map[int32]int32{} | |
| 230 | + | closeSeat(s.handle) | |
| 231 | + | s.handle = 0 | |
| 232 | + | current = nil | |
| 233 | + | s.log.Info().Msg("seat closed") | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | // Package seat opens a libseat session (logind or seatd) and hands out device fds. | |||
| 2 | + | package seat | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "runtime" | |||
| 9 | + | "sync" | |||
| 10 | + | "unsafe" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/purego" | |||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var ( | |||
| 18 | + | loadOnce sync.Once | |||
| 19 | + | loadErr error | |||
| 20 | + | ||||
| 21 | + | openSeat func(listener unsafe.Pointer, data uintptr) uintptr | |||
| 22 | + | closeSeat func(seat uintptr) int32 | |||
| 23 | + | disableSeat func(seat uintptr) int32 | |||
| 24 | + | openDevice func(seat uintptr, path string, fd *int32) int32 | |||
| 25 | + | closeDevice func(seat uintptr, id int32) int32 | |||
| 26 | + | seatName func(seat uintptr) string | |||
| 27 | + | switchSession func(seat uintptr, session int32) int32 | |||
| 28 | + | getFD func(seat uintptr) int32 | |||
| 29 | + | dispatch func(seat uintptr, timeout int32) int32 | |||
| 30 | + | ||||
| 31 | + | listener [2]uintptr | |||
| 32 | + | // libseat allows one seat per process; callbacks find it here. | |||
| 33 | + | current *Seat | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | func load() error { | |||
| 37 | + | loadOnce.Do(func() { | |||
| 38 | + | lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL) | |||
| 39 | + | if err != nil { | |||
| 40 | + | loadErr = fmt.Errorf("open libseat.so.1: %w", err) | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | defer func() { | |||
| 44 | + | if r := recover(); r != nil { | |||
| 45 | + | loadErr = fmt.Errorf("libseat: %v", r) | |||
| 46 | + | } | |||
| 47 | + | }() | |||
| 48 | + | reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) } | |||
| 49 | + | reg(&openSeat, "open_seat") | |||
| 50 | + | reg(&closeSeat, "close_seat") | |||
| 51 | + | reg(&disableSeat, "disable_seat") | |||
| 52 | + | reg(&openDevice, "open_device") | |||
| 53 | + | reg(&closeDevice, "close_device") | |||
| 54 | + | reg(&seatName, "seat_name") | |||
| 55 | + | reg(&switchSession, "switch_session") | |||
| 56 | + | reg(&getFD, "get_fd") | |||
| 57 | + | reg(&dispatch, "dispatch") | |||
| 58 | + | listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) }) | |||
| 59 | + | listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) }) | |||
| 60 | + | }) | |||
| 61 | + | return loadErr | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Seat owns the libseat handle. libseat is not thread-safe, so every call goes | |||
| 65 | + | // through mu; callbacks only run inside dispatch, which already holds it. | |||
| 66 | + | type Seat struct { | |||
| 67 | + | mu sync.Mutex | |||
| 68 | + | handle uintptr | |||
| 69 | + | active bool | |||
| 70 | + | devices map[int32]int32 // fd → libseat device id | |||
| 71 | + | subs []chan bool | |||
| 72 | + | log zerowrap.Logger | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Open connects to logind or seatd and waits for the first enable. | |||
| 76 | + | func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) { | |||
| 77 | + | if err := load(); err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | if current != nil { | |||
| 81 | + | return nil, errors.New("seat already open") | |||
| 82 | + | } | |||
| 83 | + | s := &Seat{devices: make(map[int32]int32), log: log} | |||
| 84 | + | current = s | |||
| 85 | + | s.mu.Lock() | |||
| 86 | + | defer s.mu.Unlock() | |||
| 87 | + | s.handle = openSeat(unsafe.Pointer(&listener), 0) | |||
| 88 | + | runtime.KeepAlive(&listener) | |||
| 89 | + | if s.handle == 0 { | |||
| 90 | + | current = nil | |||
| 91 | + | return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)") | |||
| 92 | + | } | |||
| 93 | + | log.Info().Str("seat", seatName(s.handle)).Msg("seat opened") | |||
| 94 | + | for !s.active { | |||
| 95 | + | if err := s.dispatchLocked(ctx, 100); err != nil { | |||
| 96 | + | s.closeLocked() | |||
| 97 | + | return nil, fmt.Errorf("waiting for seat enable: %w", err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return s, nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (s *Seat) changed(active bool) { | |||
| 104 | + | s.active = active | |||
| 105 | + | s.log.Info().Bool("active", active).Msg("seat") | |||
| 106 | + | if !active { | |||
| 107 | + | // Devices are revoked by the seat manager; ack so the VT can switch. | |||
| 108 | + | disableSeat(s.handle) | |||
| 109 | + | } | |||
| 110 | + | for _, c := range s.subs { | |||
| 111 | + | select { | |||
| 112 | + | case c <- active: | |||
| 113 | + | default: | |||
| 114 | + | select { | |||
| 115 | + | case <-c: | |||
| 116 | + | default: | |||
| 117 | + | } | |||
| 118 | + | c <- active | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // Name returns the seat name, e.g. seat0. | |||
| 124 | + | func (s *Seat) Name() string { | |||
| 125 | + | s.mu.Lock() | |||
| 126 | + | defer s.mu.Unlock() | |||
| 127 | + | return seatName(s.handle) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Subscribe returns a channel that receives the seat state after every enable/disable. | |||
| 131 | + | func (s *Seat) Subscribe() <-chan bool { | |||
| 132 | + | c := make(chan bool, 1) | |||
| 133 | + | s.mu.Lock() | |||
| 134 | + | s.subs = append(s.subs, c) | |||
| 135 | + | s.mu.Unlock() | |||
| 136 | + | return c | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error { | |||
| 140 | + | if ctx.Err() != nil { | |||
| 141 | + | return ctx.Err() | |||
| 142 | + | } | |||
| 143 | + | fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}} | |||
| 144 | + | if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) { | |||
| 145 | + | return err | |||
| 146 | + | } | |||
| 147 | + | if dispatch(s.handle, 0) < 0 { | |||
| 148 | + | return errors.New("libseat_dispatch failed") | |||
| 149 | + | } | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // Run dispatches seat events until ctx ends. The owner calls Close afterwards, | |||
| 154 | + | // once outputs have restored their CRTC state. | |||
| 155 | + | func (s *Seat) Run(ctx context.Context) error { | |||
| 156 | + | for ctx.Err() == nil { | |||
| 157 | + | fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}} | |||
| 158 | + | n, err := unix.Poll(fds, 100) | |||
| 159 | + | if err != nil && !errors.Is(err, unix.EINTR) { | |||
| 160 | + | return fmt.Errorf("seat poll: %w", err) | |||
| 161 | + | } | |||
| 162 | + | if n <= 0 { | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | s.mu.Lock() | |||
| 166 | + | r := dispatch(s.handle, 0) | |||
| 167 | + | s.mu.Unlock() | |||
| 168 | + | if r < 0 { | |||
| 169 | + | return errors.New("libseat_dispatch failed") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return nil | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // OpenDevice opens path through the seat manager and returns its fd. | |||
| 176 | + | func (s *Seat) OpenDevice(path string) (int, error) { | |||
| 177 | + | s.mu.Lock() | |||
| 178 | + | defer s.mu.Unlock() | |||
| 179 | + | var fd int32 = -1 | |||
| 180 | + | id := openDevice(s.handle, path, &fd) | |||
| 181 | + | if id < 0 { | |||
| 182 | + | s.log.Warn().Str("path", path).Msg("open device failed") | |||
| 183 | + | return -1, fmt.Errorf("libseat_open_device %s failed", path) | |||
| 184 | + | } | |||
| 185 | + | s.devices[fd] = id | |||
| 186 | + | s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened") | |||
| 187 | + | return int(fd), nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // CloseDevice releases a fd returned by OpenDevice. | |||
| 191 | + | func (s *Seat) CloseDevice(fd int) { | |||
| 192 | + | s.mu.Lock() | |||
| 193 | + | defer s.mu.Unlock() | |||
| 194 | + | id, ok := s.devices[int32(fd)] | |||
| 195 | + | if !ok { | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | delete(s.devices, int32(fd)) | |||
| 199 | + | closeDevice(s.handle, id) | |||
| 200 | + | _ = unix.Close(fd) | |||
| 201 | + | s.log.Info().Int("fd", fd).Msg("device closed") | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // SwitchVT asks the seat manager to switch to virtual terminal vt. | |||
| 205 | + | func (s *Seat) SwitchVT(vt int) { | |||
| 206 | + | s.mu.Lock() | |||
| 207 | + | defer s.mu.Unlock() | |||
| 208 | + | s.log.Info().Int("vt", vt).Msg("switch vt") | |||
| 209 | + | if switchSession(s.handle, int32(vt)) < 0 { | |||
| 210 | + | s.log.Warn().Int("vt", vt).Msg("switch vt failed") | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // Close releases all devices and the seat; the VT returns to text mode. Safe to call twice. | |||
| 215 | + | func (s *Seat) Close() { | |||
| 216 | + | s.mu.Lock() | |||
| 217 | + | defer s.mu.Unlock() | |||
| 218 | + | s.closeLocked() | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (s *Seat) closeLocked() { | |||
| 222 | + | if s.handle == 0 { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | for fd, id := range s.devices { | |||
| 226 | + | closeDevice(s.handle, id) | |||
| 227 | + | _ = unix.Close(int(fd)) | |||
| 228 | + | } | |||
| 229 | + | s.devices = map[int32]int32{} | |||
| 230 | + | closeSeat(s.handle) | |||
| 231 | + | s.handle = 0 | |||
| 232 | + | current = nil | |||
| 233 | + | s.log.Info().Msg("seat closed") | |||
| 234 | + | } | |||
M internal/app/app.go +39 -9
| @@ -5,16 +5,17 @@ import ( | |||
| 5 | 5 | "bufio" | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "fmt" | |
| 9 | 8 | "io" | |
| 10 | 9 | "os" | |
| 11 | 10 | "sync" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| 14 | 13 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 14 | + | "github.com/bnema/nefertty/internal/adapters/drm" | |
| 15 | 15 | "github.com/bnema/nefertty/internal/adapters/headless" | |
| 16 | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 17 | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 18 | + | "github.com/bnema/nefertty/internal/adapters/libinput" | |
| 18 | 19 | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 19 | 20 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 20 | 21 | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| @@ -39,17 +40,24 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |||
| 39 | 40 | ||
| 40 | 41 | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | |
| 41 | 42 | log := logging.For(ctx, "app") | |
| 42 | - | if opts.Backend != "headless" { | |
| 43 | - | return fmt.Errorf("drm backend not implemented yet") | |
| 44 | - | } | |
| 45 | 43 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |
| 46 | 44 | var cancel context.CancelFunc | |
| 47 | 45 | if opts.Timeout > 0 { | |
| 48 | - | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | |
| 46 | + | ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout) | |
| 49 | 47 | } else { | |
| 50 | 48 | ctx, cancel = context.WithCancel(ctx) | |
| 51 | 49 | } | |
| 52 | 50 | defer cancel() | |
| 51 | + | width, height := 1920, 1080 | |
| 52 | + | var hw *drmBackend | |
| 53 | + | if opts.Backend == "drm" { | |
| 54 | + | var err error | |
| 55 | + | if hw, err = openDRM(ctx); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | defer hw.close() | |
| 59 | + | width, height = hw.out.Width(), hw.out.Height() | |
| 60 | + | } | |
| 53 | 61 | client := make(chan ports.ClientEvent, 32) | |
| 54 | 62 | input := make(chan ports.InputEvent, 32) | |
| 55 | 63 | output := make(chan ports.OutputEvent, 32) | |
| @@ -65,14 +73,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 65 | 73 | if err != nil { | |
| 66 | 74 | return err | |
| 67 | 75 | } | |
| 68 | - | output <- ports.OutputMode{Width: 1920, Height: 1080} | |
| 76 | + | output <- ports.OutputMode{Width: width, Height: height} | |
| 69 | 77 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |
| 70 | 78 | if err != nil { | |
| 71 | 79 | return err | |
| 72 | 80 | } | |
| 73 | 81 | keymap := km.String() | |
| 74 | 82 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | |
| 75 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | |
| 83 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | |
| 76 | 84 | if err != nil { | |
| 77 | 85 | km.Close() | |
| 78 | 86 | return err | |
| @@ -87,7 +95,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 87 | 95 | } | |
| 88 | 96 | var workers sync.WaitGroup | |
| 89 | 97 | workers.Add(7) | |
| 90 | - | done := make(chan error, 7) | |
| 98 | + | done := make(chan error, 8) | |
| 91 | 99 | path := opts.ConfigPath | |
| 92 | 100 | if path == "" { | |
| 93 | 101 | path = config.DefaultPath() | |
| @@ -99,6 +107,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 99 | 107 | script := make(chan string) | |
| 100 | 108 | go func() { | |
| 101 | 109 | defer workers.Done() | |
| 110 | + | if hw != nil { | |
| 111 | + | done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input) | |
| 112 | + | return | |
| 113 | + | } | |
| 102 | 114 | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | |
| 103 | 115 | }() | |
| 104 | 116 | if opts.Script != nil { | |
| @@ -127,6 +139,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 127 | 139 | ||
| 128 | 140 | go func() { | |
| 129 | 141 | defer workers.Done() | |
| 142 | + | if hw != nil { | |
| 143 | + | done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) { | |
| 144 | + | r, err := vulkan.New(w, h) | |
| 145 | + | if err != nil { | |
| 146 | + | return nil, err | |
| 147 | + | } | |
| 148 | + | return r, nil | |
| 149 | + | }, hw.seat.Subscribe(), renderScenes, contents) | |
| 150 | + | return | |
| 151 | + | } | |
| 130 | 152 | done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) { | |
| 131 | 153 | r, err := vulkan.New(w, h) | |
| 132 | 154 | if err != nil { | |
| @@ -136,10 +158,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 136 | 158 | }}, renderScenes, contents) | |
| 137 | 159 | }() | |
| 138 | 160 | ||
| 161 | + | if hw != nil { | |
| 162 | + | workers.Add(1) | |
| 163 | + | go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }() | |
| 164 | + | } | |
| 139 | 165 | var result error | |
| 140 | 166 | select { | |
| 141 | 167 | case <-ctx.Done(): | |
| 168 | + | if errors.Is(context.Cause(ctx), errTimeout) { | |
| 169 | + | log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping") | |
| 170 | + | } | |
| 142 | 171 | case result = <-done: | |
| 172 | + | log.Info().AnErr("cause", result).Msg("stopping") | |
| 143 | 173 | } | |
| 144 | 174 | cancel() | |
| 145 | 175 | workers.Wait() | |
| @@ -149,7 +179,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 149 | 179 | result = err | |
| 150 | 180 | } | |
| 151 | 181 | } | |
| 152 | - | if errors.Is(result, core.ErrQuit) { | |
| 182 | + | if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) { | |
| 153 | 183 | return nil | |
| 154 | 184 | } | |
| 155 | 185 | return result | |
| @@ -5,16 +5,17 @@ import ( | |||||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | - | "fmt" | |||
| 9 | "io" | 8 | "io" | ||
| 10 | "os" | 9 | "os" | ||
| 11 | "sync" | 10 | "sync" | ||
| 12 | "time" | 11 | "time" | ||
| 13 | 12 | ||||
| 14 | "github.com/bnema/nefertty/internal/adapters/config" | 13 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/drm" | |||
| 15 | "github.com/bnema/nefertty/internal/adapters/headless" | 15 | "github.com/bnema/nefertty/internal/adapters/headless" | ||
| 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | ||
| 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | ||
| 18 | + | "github.com/bnema/nefertty/internal/adapters/libinput" | |||
| 18 | "github.com/bnema/nefertty/internal/adapters/vulkan" | 19 | "github.com/bnema/nefertty/internal/adapters/vulkan" | ||
| 19 | "github.com/bnema/nefertty/internal/adapters/wayland" | 20 | "github.com/bnema/nefertty/internal/adapters/wayland" | ||
| 20 | "github.com/bnema/nefertty/internal/adapters/xkb" | 21 | "github.com/bnema/nefertty/internal/adapters/xkb" | ||
| @@ -39,17 +40,24 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |||||
| 39 | 40 | ||||
| 40 | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | 41 | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | ||
| 41 | log := logging.For(ctx, "app") | 42 | log := logging.For(ctx, "app") | ||
| 42 | - | if opts.Backend != "headless" { | |||
| 43 | - | return fmt.Errorf("drm backend not implemented yet") | |||
| 44 | - | } | |||
| 45 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | 43 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | ||
| 46 | var cancel context.CancelFunc | 44 | var cancel context.CancelFunc | ||
| 47 | if opts.Timeout > 0 { | 45 | if opts.Timeout > 0 { | ||
| 48 | - | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | 46 | + | ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout) |
| 49 | } else { | 47 | } else { | ||
| 50 | ctx, cancel = context.WithCancel(ctx) | 48 | ctx, cancel = context.WithCancel(ctx) | ||
| 51 | } | 49 | } | ||
| 52 | defer cancel() | 50 | defer cancel() | ||
| 51 | + | width, height := 1920, 1080 | |||
| 52 | + | var hw *drmBackend | |||
| 53 | + | if opts.Backend == "drm" { | |||
| 54 | + | var err error | |||
| 55 | + | if hw, err = openDRM(ctx); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | defer hw.close() | |||
| 59 | + | width, height = hw.out.Width(), hw.out.Height() | |||
| 60 | + | } | |||
| 53 | client := make(chan ports.ClientEvent, 32) | 61 | client := make(chan ports.ClientEvent, 32) | ||
| 54 | input := make(chan ports.InputEvent, 32) | 62 | input := make(chan ports.InputEvent, 32) | ||
| 55 | output := make(chan ports.OutputEvent, 32) | 63 | output := make(chan ports.OutputEvent, 32) | ||
| @@ -65,14 +73,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 65 | if err != nil { | 73 | if err != nil { | ||
| 66 | return err | 74 | return err | ||
| 67 | } | 75 | } | ||
| 68 | - | output <- ports.OutputMode{Width: 1920, Height: 1080} | 76 | + | output <- ports.OutputMode{Width: width, Height: height} |
| 69 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | 77 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | ||
| 70 | if err != nil { | 78 | if err != nil { | ||
| 71 | return err | 79 | return err | ||
| 72 | } | 80 | } | ||
| 73 | keymap := km.String() | 81 | keymap := km.String() | ||
| 74 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | 82 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | ||
| 75 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | 83 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) |
| 76 | if err != nil { | 84 | if err != nil { | ||
| 77 | km.Close() | 85 | km.Close() | ||
| 78 | return err | 86 | return err | ||
| @@ -87,7 +95,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 87 | } | 95 | } | ||
| 88 | var workers sync.WaitGroup | 96 | var workers sync.WaitGroup | ||
| 89 | workers.Add(7) | 97 | workers.Add(7) | ||
| 90 | - | done := make(chan error, 7) | 98 | + | done := make(chan error, 8) |
| 91 | path := opts.ConfigPath | 99 | path := opts.ConfigPath | ||
| 92 | if path == "" { | 100 | if path == "" { | ||
| 93 | path = config.DefaultPath() | 101 | path = config.DefaultPath() | ||
| @@ -99,6 +107,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 99 | script := make(chan string) | 107 | script := make(chan string) | ||
| 100 | go func() { | 108 | go func() { | ||
| 101 | defer workers.Done() | 109 | defer workers.Done() | ||
| 110 | + | if hw != nil { | |||
| 111 | + | done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input) | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 102 | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | 114 | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | ||
| 103 | }() | 115 | }() | ||
| 104 | if opts.Script != nil { | 116 | if opts.Script != nil { | ||
| @@ -127,6 +139,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 127 | 139 | ||||
| 128 | go func() { | 140 | go func() { | ||
| 129 | defer workers.Done() | 141 | defer workers.Done() | ||
| 142 | + | if hw != nil { | |||
| 143 | + | done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) { | |||
| 144 | + | r, err := vulkan.New(w, h) | |||
| 145 | + | if err != nil { | |||
| 146 | + | return nil, err | |||
| 147 | + | } | |||
| 148 | + | return r, nil | |||
| 149 | + | }, hw.seat.Subscribe(), renderScenes, contents) | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 130 | done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) { | 152 | done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) { | ||
| 131 | r, err := vulkan.New(w, h) | 153 | r, err := vulkan.New(w, h) | ||
| 132 | if err != nil { | 154 | if err != nil { | ||
| @@ -136,10 +158,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 136 | }}, renderScenes, contents) | 158 | }}, renderScenes, contents) | ||
| 137 | }() | 159 | }() | ||
| 138 | 160 | ||||
| 161 | + | if hw != nil { | |||
| 162 | + | workers.Add(1) | |||
| 163 | + | go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }() | |||
| 164 | + | } | |||
| 139 | var result error | 165 | var result error | ||
| 140 | select { | 166 | select { | ||
| 141 | case <-ctx.Done(): | 167 | case <-ctx.Done(): | ||
| 168 | + | if errors.Is(context.Cause(ctx), errTimeout) { | |||
| 169 | + | log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping") | |||
| 170 | + | } | |||
| 142 | case result = <-done: | 171 | case result = <-done: | ||
| 172 | + | log.Info().AnErr("cause", result).Msg("stopping") | |||
| 143 | } | 173 | } | ||
| 144 | cancel() | 174 | cancel() | ||
| 145 | workers.Wait() | 175 | workers.Wait() | ||
| @@ -149,7 +179,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 149 | result = err | 179 | result = err | ||
| 150 | } | 180 | } | ||
| 151 | } | 181 | } | ||
| 152 | - | if errors.Is(result, core.ErrQuit) { | 182 | + | if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) { |
| 153 | return nil | 183 | return nil | ||
| 154 | } | 184 | } | ||
| 155 | return result | 185 | return result | ||
A internal/app/drm.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/drm" | |
| 11 | + | "github.com/bnema/nefertty/internal/adapters/seat" | |
| 12 | + | "github.com/bnema/nefertty/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var errTimeout = errors.New("timeout") | |
| 16 | + | ||
| 17 | + | type drmBackend struct { | |
| 18 | + | seat *seat.Seat | |
| 19 | + | out *drm.Output | |
| 20 | + | fd int | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // openDRM opens the seat and the first card with a connected display. | |
| 24 | + | func openDRM(ctx context.Context) (*drmBackend, error) { | |
| 25 | + | log := logging.For(ctx, "drm") | |
| 26 | + | s, err := seat.Open(ctx, logging.For(ctx, "seat")) | |
| 27 | + | if err != nil { | |
| 28 | + | log.Error().Err(err).Msg("open seat") | |
| 29 | + | return nil, fmt.Errorf("open seat: %w", err) | |
| 30 | + | } | |
| 31 | + | cards, _ := filepath.Glob("/dev/dri/card[0-9]*") | |
| 32 | + | sort.Strings(cards) | |
| 33 | + | var errs []error | |
| 34 | + | for _, card := range cards { | |
| 35 | + | fd, err := s.OpenDevice(card) | |
| 36 | + | if err != nil { | |
| 37 | + | errs = append(errs, err) | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | out, err := drm.Open(fd, card, log) | |
| 41 | + | if err != nil { | |
| 42 | + | log.Warn().Err(err).Str("card", card).Msg("card unusable") | |
| 43 | + | errs = append(errs, err) | |
| 44 | + | s.CloseDevice(fd) | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | return &drmBackend{seat: s, out: out, fd: fd}, nil | |
| 48 | + | } | |
| 49 | + | s.Close() | |
| 50 | + | err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...)) | |
| 51 | + | log.Error().Err(err).Msg("open drm") | |
| 52 | + | return nil, err | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // close restores the CRTC, then releases the card and the seat so the TTY comes back. | |
| 56 | + | func (b *drmBackend) close() { | |
| 57 | + | b.out.Close() | |
| 58 | + | b.seat.CloseDevice(b.fd) | |
| 59 | + | b.seat.Close() | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/drm" | |||
| 11 | + | "github.com/bnema/nefertty/internal/adapters/seat" | |||
| 12 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | var errTimeout = errors.New("timeout") | |||
| 16 | + | ||||
| 17 | + | type drmBackend struct { | |||
| 18 | + | seat *seat.Seat | |||
| 19 | + | out *drm.Output | |||
| 20 | + | fd int | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // openDRM opens the seat and the first card with a connected display. | |||
| 24 | + | func openDRM(ctx context.Context) (*drmBackend, error) { | |||
| 25 | + | log := logging.For(ctx, "drm") | |||
| 26 | + | s, err := seat.Open(ctx, logging.For(ctx, "seat")) | |||
| 27 | + | if err != nil { | |||
| 28 | + | log.Error().Err(err).Msg("open seat") | |||
| 29 | + | return nil, fmt.Errorf("open seat: %w", err) | |||
| 30 | + | } | |||
| 31 | + | cards, _ := filepath.Glob("/dev/dri/card[0-9]*") | |||
| 32 | + | sort.Strings(cards) | |||
| 33 | + | var errs []error | |||
| 34 | + | for _, card := range cards { | |||
| 35 | + | fd, err := s.OpenDevice(card) | |||
| 36 | + | if err != nil { | |||
| 37 | + | errs = append(errs, err) | |||
| 38 | + | continue | |||
| 39 | + | } | |||
| 40 | + | out, err := drm.Open(fd, card, log) | |||
| 41 | + | if err != nil { | |||
| 42 | + | log.Warn().Err(err).Str("card", card).Msg("card unusable") | |||
| 43 | + | errs = append(errs, err) | |||
| 44 | + | s.CloseDevice(fd) | |||
| 45 | + | continue | |||
| 46 | + | } | |||
| 47 | + | return &drmBackend{seat: s, out: out, fd: fd}, nil | |||
| 48 | + | } | |||
| 49 | + | s.Close() | |||
| 50 | + | err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...)) | |||
| 51 | + | log.Error().Err(err).Msg("open drm") | |||
| 52 | + | return nil, err | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // close restores the CRTC, then releases the card and the seat so the TTY comes back. | |||
| 56 | + | func (b *drmBackend) close() { | |||
| 57 | + | b.out.Close() | |||
| 58 | + | b.seat.CloseDevice(b.fd) | |||
| 59 | + | b.seat.Close() | |||
| 60 | + | } | |||
M internal/logging/logging.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | var components = map[string]bool{ | |
| 19 | - | "core": true, "wayland": true, "input": true, "drm": true, | |
| 19 | + | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | |
| 20 | 20 | "render": true, "sync": true, "config": true, "app": true, | |
| 21 | 21 | } | |
| 22 | 22 | ||
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | var components = map[string]bool{ | 18 | var components = map[string]bool{ | ||
| 19 | - | "core": true, "wayland": true, "input": true, "drm": true, | 19 | + | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, |
| 20 | "render": true, "sync": true, "config": true, "app": true, | 20 | "render": true, "sync": true, "config": true, "app": true, | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||