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