471 lines
14 KiB
Go
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
|
|
}
|