// 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 . //go:build linux && cgo // Package console implements Linux console and libevdev/uinput boundaries. package console /* #cgo pkg-config: libevdev #include #include #include #include #include #include #include #include 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 }