After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
// 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
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 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
}