// 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 terminal adapts an X11 terminal and child PTY to the shared reader. // The application loop owns all X11, emulator, input and screen state. Only // readiness waiting and child reaping run in background goroutines. package terminal /* #cgo pkg-config: x11 vterm #cgo LDFLAGS: -lutil #include "native.h" */ import "C" import ( "errors" "fmt" "os" "os/exec" "strconv" "sync" "syscall" "time" "git.stormux.org/storm/fenrir/internal/input" ) // ErrClosed marks normal PTY or launching-terminal EOF, rather than an I/O // failure in another service such as Speech Dispatcher. var ErrClosed = errors.New("terminal session ended") type Session struct { input, output *os.File master int x *C.TerminalX vt *Emulator saved C.struct_termios flags C.int raw bool rows, cols int active, lock bool capsLock bool router input.Router held map[int]uint16 replayed map[int]uint16 grabbed bool captureBlocked bool pending []byte outputPending []byte pasteStart, pasteEnd int pasteGeneration uint64 events chan error inputEvents chan error arm chan waitState stop chan struct{} wake [2]int workers sync.WaitGroup child *exec.Cmd exited chan struct{} closed bool eof bool capsRestore, capsConsumed bool lastReplay struct { code int time uint64 } } func native_error(code C.int) error { if code < 0 { return syscall.Errno(-code) } return nil } func valid_size(rows, cols int) bool { return rows > 0 && cols > 0 && rows <= 1000 && cols <= 2000 && rows*cols <= 1_000_000 } // Open starts the user's shell in a fresh controlling PTY. WINDOWID must name // the launching terminal; guessing the currently focused window risks grabbing // an unrelated application. There is no console-device or privilege access. func Open(inputFile, outputFile *os.File, router input.Router) (*Session, error) { window, err := strconv.ParseUint(os.Getenv("WINDOWID"), 0, 32) if err != nil || window == 0 { return nil, fmt.Errorf("-x requires a valid WINDOWID from the launching X11 terminal") } shell := os.Getenv("SHELL") if shell == "" { return nil, fmt.Errorf("-x requires SHELL to name your shell") } return open_session(inputFile, outputFile, window, exec.Command(shell), router) } func open_session(inputFile, outputFile *os.File, window uint64, child *exec.Cmd, router input.Router) (_ *Session, result error) { s := &Session{input: inputFile, output: outputFile, master: -1, router: router, held: make(map[int]uint16), replayed: make(map[int]uint16), events: make(chan error, 1), inputEvents: make(chan error, 1), arm: make(chan waitState, 1), stop: make(chan struct{}), exited: make(chan struct{}), wake: [2]int{-1, -1}} defer func() { if result != nil { result = errors.Join(result, s.Close()) } }() var rows, cols C.int if err := native_error(C.terminal_size(C.int(inputFile.Fd()), &rows, &cols)); err != nil { return nil, fmt.Errorf("-x requires a terminal on stdin: %w", err) } s.rows, s.cols = int(rows), int(cols) if !valid_size(s.rows, s.cols) { return nil, fmt.Errorf("unsupported terminal geometry %dx%d", s.cols, s.rows) } var xError C.int s.x = C.terminal_x_open(C.ulong(window), &xError) if s.x == nil { return nil, fmt.Errorf("open X11 terminal or acquire its keys (error %d); check DISPLAY, WINDOWID and competing grabs", xError) } var scriptKeys []C.int for _, key := range append(router.Reader_keys(), router.Script_keys()...) { scriptKeys = append(scriptKeys, C.int(key)) } if len(scriptKeys) > 0 { if err := native_error(C.terminal_x_script_keys(s.x, &scriptKeys[0], C.int(len(scriptKeys)))); err != nil { return nil, fmt.Errorf("acquire script modifier keys: %w", err) } } emulator, err := new_emulator(s.rows, s.cols) if err != nil { return nil, err } s.vt = emulator master, slave, err := open_pty(s.rows, s.cols) if err != nil { return nil, err } s.master = master slaveFile := os.NewFile(uintptr(slave), "child PTY") defer slaveFile.Close() if err := syscall.SetNonblock(master, true); err != nil { return nil, err } if err := syscall.Pipe2(s.wake[:], syscall.O_CLOEXEC|syscall.O_NONBLOCK); err != nil { return nil, err } if err := native_error(C.terminal_raw(C.int(inputFile.Fd()), &s.saved, &s.flags)); err != nil { return nil, fmt.Errorf("set terminal mode: %w", err) } s.raw = true child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} child.Env = append(os.Environ(), "FENRIR_TERMINAL=1") if err := child.Start(); err != nil { return nil, fmt.Errorf("start PTY shell: %w", err) } s.child = child go func() { _ = child.Wait() close(s.exited) }() s.workers.Add(1) go s.watch(int(C.terminal_x_fd(s.x))) return s, nil } func (s *Session) State() (int, bool, bool, error) { var active, lock, capsLock, rows, cols C.int if err := native_error(C.terminal_x_state(s.x, &active, &lock, &capsLock)); err != nil { return 0, false, false, err } if s.active && active == 0 { C.terminal_x_release(s.x) s.router.Reset() clear(s.held) clear(s.replayed) s.grabbed = false } s.active, s.lock = active != 0, lock != 0 s.retire_paste() s.capsLock = capsLock != 0 // Synchronous X queries can buffer key events before the readiness worker // sees the socket. Preserve that wakeup even when Xlib drained the fd. if C.terminal_x_pending(s.x) != 0 { select { case s.inputEvents <- nil: default: } } if err := native_error(C.terminal_size(C.int(s.input.Fd()), &rows, &cols)); err != nil { return 0, false, false, err } if int(rows) != s.rows || int(cols) != s.cols { if !valid_size(int(rows), int(cols)) { return 0, false, false, fmt.Errorf("unsupported terminal geometry %dx%d", cols, rows) } if err := native_error(C.terminal_resize(C.int(s.master), rows, cols)); err != nil { return 0, false, false, err } s.vt.resize(int(rows), int(cols)) s.rows, s.cols = int(rows), int(cols) select { case s.events <- nil: default: } } return 1, s.active, s.lock, nil } func (s *Session) Close() error { if s.closed { return nil } s.closed = true close(s.stop) if s.wake[1] >= 0 { _, _ = syscall.Write(s.wake[1], []byte{1}) } s.workers.Wait() var result error if s.raw { result = native_error(C.terminal_restore(C.int(s.input.Fd()), &s.saved, s.flags)) // A child may exit while its alternate screen or input modes are // active. Restore xterm's normal screen, cursor and keyboard modes. _, err := s.output.WriteString("\x1b[?1049l\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m") result = errors.Join(result, err) } if s.x != nil { C.terminal_x_close(s.x) } if s.master >= 0 { result = errors.Join(result, syscall.Close(s.master)) } if s.child != nil { _ = s.child.Process.Signal(syscall.SIGHUP) select { case <-s.exited: case <-time.After(500 * time.Millisecond): _ = syscall.Kill(-s.child.Process.Pid, syscall.SIGKILL) _ = s.child.Process.Kill() <-s.exited } } if s.vt != nil { s.vt.close() } for _, fd := range s.wake { if fd >= 0 { result = errors.Join(result, syscall.Close(fd)) } } return result }