Files

471 lines
14 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
//go:build linux && cgo
// Package console implements Linux console and libevdev/uinput boundaries.
package console
/*
#cgo pkg-config: libevdev
#include <libevdev/libevdev.h>
#include <libevdev/libevdev-uinput.h>
#include <linux/kd.h>
#include <linux/vt.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
static int console_state(int fd, int *mode, unsigned char *leds) {
if (ioctl(fd, KDGETMODE, mode) < 0) return -errno;
if (*mode == KD_TEXT && ioctl(fd, KDGKBLED, leds) < 0) return -errno;
return 0;
}
static int toggle_caps_lock(int fd) {
unsigned char leds;
if (ioctl(fd, KDGKBLED, &leds) < 0) return -errno;
if (ioctl(fd, KDSKBLED, (unsigned long)(leds ^ LED_CAP)) < 0) return -errno;
return 0;
}
static int console_usage(int fd, unsigned short *mask) {
struct vt_stat state;
if (ioctl(fd, VT_GETSTATE, &state) < 0) return -errno;
*mask = state.v_state;
return 0;
}
static int read_feedback(const struct libevdev_uinput *device, struct input_event *event) {
ssize_t count = read(libevdev_uinput_get_fd(device), event, sizeof(*event));
if (count < 0) return -errno;
return count == sizeof(*event) ? 0 : -EIO;
}
static long long input_event_time_us(const struct input_event *event) {
return (long long)event->input_event_sec * 1000000 + event->input_event_usec;
}
*/
import "C"
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"git.stormux.org/storm/fenrir/internal/diagnostic"
"git.stormux.org/storm/fenrir/internal/input"
)
func kernel_error(code C.int) error {
if code >= 0 {
return nil
}
return syscall.Errno(-code)
}
func query_usage(path string) (uint16, error) {
file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOCTTY, 0)
if err != nil {
return 0, err
}
defer file.Close()
var mask C.ushort
if err := kernel_error(C.console_usage(C.int(file.Fd()), &mask)); err != nil {
return 0, err
}
return uint16(mask), nil
}
func query_state(path string) (bool, bool, bool, error) {
file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOCTTY, 0)
if err != nil {
return false, false, false, err
}
defer file.Close()
var mode C.int
var leds C.uchar
if err := kernel_error(C.console_state(C.int(file.Fd()), &mode, &leds)); err != nil {
return false, false, false, err
}
return mode == C.KD_TEXT, leds&C.LED_NUM != 0, leds&C.LED_CAP != 0, nil
}
type Device struct {
path string
fd int
dev *C.struct_libevdev
virtual *C.struct_libevdev_uinput
grabbed bool
syncing bool
}
func open_device(path string) (*Device, error) {
fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
device := &Device{path: path, fd: fd}
if err := kernel_error(C.libevdev_new_from_fd(C.int(fd), &device.dev)); err != nil {
syscall.Close(fd)
return nil, err
}
return device, nil
}
func (d *Device) keyboard() bool {
name := C.GoString(C.libevdev_get_name(d.dev))
if strings.HasPrefix(name, "fenrir ") || name == "fenrir-uinput" {
return false
}
has := func(key C.uint) bool { return C.libevdev_has_event_code(d.dev, C.EV_KEY, key) != 0 }
return has(C.KEY_A) && has(C.KEY_Z) && has(C.KEY_ENTER) || has(C.KEY_KP7) && has(C.KEY_KP5) && has(C.KEY_KP1)
}
func (d *Device) create_forwarder() error {
name := C.CString("fenrir forwarding " + filepath.Base(d.path))
defer C.free(unsafe.Pointer(name))
C.libevdev_set_name(d.dev, name)
// The physical keyboard already generates repeats. A second repeating
// virtual device would duplicate them.
C.libevdev_disable_event_type(d.dev, C.EV_REP)
if err := kernel_error(C.libevdev_uinput_create_from_device(d.dev, C.LIBEVDEV_UINPUT_OPEN_MANAGED, &d.virtual)); err != nil {
return err
}
return syscall.SetNonblock(int(C.libevdev_uinput_get_fd(d.virtual)), true)
}
func (d *Device) forward(eventType, code uint16, value int32) error {
return kernel_error(C.libevdev_uinput_write_event(d.virtual, C.uint(eventType), C.uint(code), C.int(value)))
}
func (d *Device) held() bool {
for key := C.uint(0); key <= C.KEY_MAX; key++ {
if C.libevdev_get_event_value(d.dev, C.EV_KEY, key) != 0 {
return true
}
}
return false
}
func (d *Device) release(router *input.Router) error {
var errs []error
if d.virtual != nil {
for _, key := range router.Forwarded(d.fd) {
errs = append(errs, d.forward(C.EV_KEY, key, 0))
}
errs = append(errs, d.forward(C.EV_SYN, C.SYN_REPORT, 0))
}
router.Drop(d.fd)
if d.grabbed {
errs = append(errs, kernel_error(C.libevdev_grab(d.dev, C.LIBEVDEV_UNGRAB)))
d.grabbed = false
}
return errors.Join(errs...)
}
func (d *Device) close(router *input.Router) error {
err := d.release(router)
if d.virtual != nil {
C.libevdev_uinput_destroy(d.virtual)
d.virtual = nil
}
C.libevdev_free(d.dev)
return errors.Join(err, syscall.Close(d.fd))
}
// Keyboards owns every native handle on the application's event loop.
// It never grabs until screen access and the speech connection are ready.
type Keyboards struct {
devices map[string]*Device
rejected map[string]deviceIdentity
paths []string
router input.Router
watcher *readinessWatcher
uiMap *os.File
}
func Open_keyboards(paths []string, router input.Router) (*Keyboards, error) {
watcher, err := newReadinessWatcher(syscall.EPOLLIN)
if err != nil {
return nil, fmt.Errorf("watch keyboards: %w", err)
}
keyboards := &Keyboards{devices: make(map[string]*Device), paths: paths, watcher: watcher, router: router}
if err := keyboards.Scan(); err != nil {
keyboards.Close()
return nil, err
}
if len(keyboards.devices) == 0 {
keyboards.Close()
return nil, fmt.Errorf("no accessible keyboards in /dev/input/event* (TTY mode needs privileged evdev access)")
}
return keyboards, nil
}
func (k *Keyboards) Scan() error {
paths := k.paths
if len(paths) == 0 {
paths, _ = filepath.Glob("/dev/input/event*")
}
return k.scan_paths(paths)
}
func (k *Keyboards) scan_paths(paths []string) error {
started := time.Now()
probed, skipped := 0, 0
defer func() { diagnostic.Timing("keyboard scan", started, "probed", probed, "cached", skipped) }()
if k.rejected == nil {
k.rejected = make(map[string]deviceIdentity)
}
// Rebuild the negative cache so disappeared devices do not accumulate.
rejected := make(map[string]deviceIdentity)
defer func() { k.rejected = rejected }()
for _, path := range paths {
if k.devices[path] != nil {
continue
}
identity, statErr := device_identity(path)
if cached, ok := k.rejected[path]; ok && statErr == nil && cached == identity {
rejected[path] = cached
skipped++
continue
}
probed++
device, err := open_device(path)
if err != nil {
if len(k.paths) != 0 {
return fmt.Errorf("open keyboard %s: %w", path, err)
}
continue
}
if !device.keyboard() {
// Cache the opened device, not a potentially replaced path. Failed
// opens remain retryable; hotplug creates a different node identity.
var stat syscall.Stat_t
if err := syscall.Fstat(device.fd, &stat); err == nil {
rejected[path] = identity_from_stat(&stat)
}
device.close(&k.router)
if len(k.paths) != 0 {
return fmt.Errorf("%s is not a supported keyboard", path)
}
continue
}
if err := device.create_forwarder(); err != nil {
device.close(&k.router)
return fmt.Errorf("create uinput forwarder for %s (is /dev/uinput available?): %w", path, err)
}
if err := k.watcher.add(device.fd); err != nil {
device.close(&k.router)
return fmt.Errorf("watch keyboard %s: %w", path, err)
}
k.devices[path] = device
}
return nil
}
func (k *Keyboards) Pass_next() { k.router.Pass_next() }
// Events wakes the application when a keyboard is readable, without reading it.
// A nil event means readiness; a non-nil event is a fatal watcher error.
func (k *Keyboards) Events() <-chan error { return k.watcher.events }
func (k *Keyboards) Close() error {
var errs []error
if k.uiMap != nil {
errs = append(errs, k.uiMap.Close())
k.uiMap = nil
}
if k.watcher != nil {
errs = append(errs, k.watcher.close())
k.watcher = nil
}
for path, device := range k.devices {
errs = append(errs, device.close(&k.router))
delete(k.devices, path)
}
k.router.Reset()
return errors.Join(errs...)
}
// Update_modifiers waits for every keyboard to release its old chord before
// replacing the router; the existing evdev grabs remain owned throughout.
func (k *Keyboards) Update_modifiers(fenrirKeys, scriptKeys []string) (bool, error) {
if k.router.Captured() || !k.router.Idle() {
return false, nil
}
for _, device := range k.devices {
if device.syncing || device.held() {
return false, nil
}
}
candidate := k.router
if err := candidate.Configure_modifiers(fenrirKeys, scriptKeys); err != nil {
return false, err
}
k.router = candidate
return true, nil
}
func (k *Keyboards) Poll(textMode, numLock bool, handle func(input.Decision)) error {
if !textMode {
for _, device := range k.devices {
if device.grabbed {
if err := device.release(&k.router); err != nil {
return err
}
}
}
k.router.Reset()
}
for path, device := range k.devices {
drained, err := k.drain(device, numLock, handle)
if errors.Is(err, syscall.ENODEV) || errors.Is(err, syscall.EIO) {
device.close(&k.router)
delete(k.devices, path)
continue
}
if err != nil {
return fmt.Errorf("keyboard %s: %w", path, err)
}
if !drained {
// libevdev may retain buffered events after the bounded drain.
// Schedule another pass even if the kernel fd is no longer readable.
k.watcher.notify()
}
// Wait for all physical keys to be released before taking ownership.
// This avoids stealing half a Ctrl+Alt+Fn chord at a VT switch.
if textMode && !device.grabbed && drained && !device.held() {
if err := kernel_error(C.libevdev_grab(device.dev, C.LIBEVDEV_GRAB)); err != nil {
return fmt.Errorf("grab %s (another reader may own it): %w", path, err)
}
// A press can race the last read and EVIOCGRAB. Refresh the kernel
// state without forwarding commands before accepting ownership.
var event C.struct_input_event
code := C.libevdev_next_event(device.dev, C.LIBEVDEV_READ_FLAG_FORCE_SYNC, &event)
if code < 0 {
C.libevdev_grab(device.dev, C.LIBEVDEV_UNGRAB)
return kernel_error(code)
}
device.syncing = true
ready, err := k.drain(device, numLock, handle)
if err != nil || !ready || device.held() {
C.libevdev_grab(device.dev, C.LIBEVDEV_UNGRAB)
if err != nil {
return err
}
if !ready {
k.watcher.notify()
}
} else {
device.grabbed = true
}
}
if err := k.watcher.arm(device.fd); err != nil {
return fmt.Errorf("rearm keyboard %s: %w", path, err)
}
}
return nil
}
func (k *Keyboards) drain(device *Device, numLock bool, handle func(input.Decision)) (bool, error) {
// Bound each pass so busy input can't starve VT checks, speech, or quit.
for count := 0; count < 256; count++ {
var event C.struct_input_event
flags := C.uint(C.LIBEVDEV_READ_FLAG_NORMAL)
if device.syncing {
flags = C.LIBEVDEV_READ_FLAG_SYNC
}
code := C.libevdev_next_event(device.dev, flags, &event)
if code == -C.EAGAIN {
if device.syncing {
device.syncing = false
continue
}
return true, device.feedback()
}
if code < 0 {
return false, kernel_error(code)
}
if code == C.LIBEVDEV_READ_STATUS_SYNC && !device.syncing {
device.syncing = true
continue
}
if !device.grabbed {
continue
}
decision := input.Decision{Forward: true}
if event._type == C.EV_KEY {
if event.value != 0 && !device.syncing && diagnostic.Enabled() {
diagnostic.Timing("keyboard event age", time.UnixMicro(int64(C.input_event_time_us(&event))))
}
decision = k.router.Key(device.fd, uint16(event.code), int32(event.value), numLock, device.syncing)
if err := k.ui_text(uint16(event.code), decision); err != nil {
return false, err
}
}
if decision.ToggleCapsLock {
file, err := os.OpenFile("/dev/tty0", os.O_RDONLY|syscall.O_NOCTTY, 0)
if err != nil {
return false, err
}
toggleErr := kernel_error(C.toggle_caps_lock(C.int(file.Fd())))
if err := errors.Join(toggleErr, file.Close()); err != nil {
return false, err
}
}
if decision.Forward {
if err := device.forward(uint16(event._type), uint16(event.code), int32(event.value)); err != nil {
return false, err
}
}
if decision.Keypress || decision.Interrupt || decision.Command != "" || decision.UI != nil {
handle(decision)
}
}
return false, nil
}
func (d *Device) feedback() error {
for count := 0; count < 64; count++ {
var event C.struct_input_event
code := C.read_feedback(d.virtual, &event)
if code == -C.EAGAIN {
return nil
}
if code < 0 {
return kernel_error(code)
}
if event._type == C.EV_LED {
value := C.enum_libevdev_led_value(C.LIBEVDEV_LED_OFF)
if event.value != 0 {
value = C.LIBEVDEV_LED_ON
}
if err := kernel_error(C.libevdev_kernel_set_led_value(d.dev, C.uint(event.code), value)); err != nil {
return err
}
}
}
return nil
}