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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user