237 lines
7.7 KiB
Go
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
|
|
}
|