Files

271 lines
8.2 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 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
}