Files

237 lines
7.7 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 console
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"git.stormux.org/storm/fenrir/internal/diagnostic"
"git.stormux.org/storm/fenrir/internal/screen"
)
type Source struct {
root string
state func(string) (bool, bool, bool, error)
usage func(string) (uint16, error)
changes *os.File
watcher *readinessWatcher
ignoredVTs atomic.Uint64 // Paste checks policy from its background worker.
}
// Configure_ignored_vts receives the validated console policy from the event loop.
func (s *Source) Configure_ignored_vts(numbers []int) {
var mask uint64
for _, number := range numbers {
mask |= uint64(1) << uint(number)
}
s.ignoredVTs.Store(mask)
}
// OpenSource watches the foreground-console alias. Kernel POLLPRI notifications
// follow the active VT, so text feedback needs no periodic screen sampler.
func OpenSource() (*Source, error) {
changes, err := os.Open("/dev/vcsa")
if err != nil {
return nil, fmt.Errorf("open foreground console notifications: %w", err)
}
watcher, err := newReadinessWatcher(syscall.EPOLLPRI)
if err != nil {
changes.Close()
return nil, err
}
source := &Source{root: "/", state: query_state, usage: query_usage, changes: changes, watcher: watcher}
if err := watcher.add(int(changes.Fd())); err != nil {
source.Close()
return nil, fmt.Errorf("watch foreground console: %w", err)
}
return source, nil
}
func (s *Source) Events() <-chan error { return s.watcher.events }
func (s *Source) Close() error {
return errors.Join(s.watcher.close(), s.changes.Close())
}
func (s *Source) active() (int, bool, bool, bool, error) {
data, err := os.ReadFile(filepath.Join(s.root, "sys/devices/virtual/tty/tty0/active"))
if err != nil {
return 0, false, false, false, fmt.Errorf("read active VT: %w", err)
}
name := strings.TrimSpace(string(data))
if !strings.HasPrefix(name, "tty") {
return 0, false, false, false, fmt.Errorf("invalid active VT %q", name)
}
number, err := strconv.Atoi(strings.TrimPrefix(name, "tty"))
if err != nil || number < 1 || number > 63 {
return 0, false, false, false, fmt.Errorf("invalid active VT %q", name)
}
if s.ignoredVTs.Load()&(uint64(1)<<uint(number)) != 0 {
return number, false, false, false, nil
}
// Opening an unused ttyN, even with O_NOCTTY, marks it occupied and can
// race an on-demand login service's VT_GETSTATE check. Query through tty1
// first, leaving the target untouched until another program has opened it.
// Do not use tty0: that alias opens the foreground console itself.
// tty1 retains its boot-console behavior; the kernel's bitmap cannot
// describe tty16..tty63, so those also retain their existing behavior.
if s.usage != nil && number > 1 && number < 16 {
mask, err := s.usage(filepath.Join(s.root, "dev/tty1"))
if err != nil {
return number, false, false, false, fmt.Errorf("query console use: %w", err)
}
if mask&(uint16(1)<<uint(number)) == 0 {
return number, false, false, false, nil
}
}
textMode, numLock, capsLock, err := s.state(filepath.Join(s.root, "dev", name))
return number, textMode, numLock, capsLock, err
}
// State is checked before every input batch, independently of screen events.
func (s *Source) State() (int, bool, bool, error) {
started := time.Now()
defer func() { diagnostic.Timing("console state", started) }()
number, textMode, numLock, _, err := s.active()
return number, textMode, numLock, err
}
// Read one screen plus one cell to detect growth without repeatedly growing
// a ReadFile buffer for a device whose stat size is zero. All requests stay
// UTF-32 aligned. A short or oversized screen still goes through Decode's
// geometry check and the caller's bounded resize retry.
func read_unicode(reader io.Reader, cells int) ([]byte, error) {
data := make([]byte, (cells+1)*4)
n, err := io.ReadFull(reader, data)
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
err = nil
}
return data[:n], err
}
func read_unicode_file(path string, cells int) ([]byte, error) {
started := time.Now()
defer func() { diagnostic.Timing("Unicode console read", started) }()
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return read_unicode(file, cells)
}
func readHeader(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
header := make([]byte, 4)
_, err = io.ReadFull(file, header)
return header, err
}
func read_attributes_file(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
// VCSA geometry is two unsigned bytes. One extra byte detects growth.
return io.ReadAll(io.LimitReader(file, 4+255*255*2+1))
}
func (s *Source) Read() (screen.Snapshot, error) {
if s.changes != nil {
// Read the watched descriptor itself: opening another vcsa descriptor
// would not acknowledge its pending POLLPRI event. Acknowledge before
// capture so a change during the snapshot still produces a new event.
var header [4]byte
if _, err := s.changes.ReadAt(header[:], 0); err != nil {
return screen.Snapshot{}, fmt.Errorf("acknowledge console update: %w", err)
}
}
snapshot, err := s.read()
if err == nil && s.watcher != nil {
err = s.watcher.arm(int(s.changes.Fd()))
}
return snapshot, err
}
func (s *Source) read() (screen.Snapshot, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
number, textMode, numLock, capsLock, err := s.active()
if err != nil {
return screen.Snapshot{}, err
}
if !textMode {
return screen.Snapshot{Terminal: number}, nil
}
header, err := read_attributes_file(filepath.Join(s.root, "dev", fmt.Sprintf("vcsa%d", number)))
if err != nil {
return screen.Snapshot{}, fmt.Errorf("read console header: %w", err)
}
text, err := read_unicode_file(filepath.Join(s.root, "dev", fmt.Sprintf("vcsu%d", number)), int(header[0])*int(header[1]))
if err != nil {
return screen.Snapshot{}, fmt.Errorf("read Unicode console /dev/vcsu%d (a UTF-8 text VT is required): %w", number, err)
}
snapshot, err := decode_snapshot(number, header, text)
if err != nil {
lastErr = err
continue
}
if err := decode_attributes(&snapshot, header); err != nil {
lastErr = err
continue
}
after, modeAfter, _, _, err := s.active()
if err != nil {
return screen.Snapshot{}, err
}
if after != number || !modeAfter {
lastErr = fmt.Errorf("active VT changed during screen read")
continue
}
// A resize between VCSA and VCSU must not corrupt review geometry.
check, err := readHeader(filepath.Join(s.root, "dev", fmt.Sprintf("vcsa%d", number)))
if err != nil {
return screen.Snapshot{}, err
}
if len(check) < 4 || check[0] != header[0] || check[1] != header[1] {
lastErr = fmt.Errorf("console resized during screen read")
continue
}
snapshot.NumLock = numLock
snapshot.CapsLock = capsLock
return snapshot, nil
}
return screen.Snapshot{}, lastErr
}