Files

204 lines
5.8 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>.
package control
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
type instance struct {
PID int `json:"pid"`
ConnectionHandoff bool `json:"connection_handoff,omitempty"`
PPID int `json:"ppid"`
SocketFiles []string `json:"socket_files"`
MainSocket bool `json:"main_socket"`
ScreenDriver string `json:"screen_driver"`
KeyboardDriver string `json:"keyboard_driver"`
WindowID string `json:"x11_window_id"`
CreatedAt float64 `json:"created_at"`
UpdatedAt float64 `json:"updated_at"`
uid int
}
func (s *Server) registry_dir() string {
if s.options.RegistryDirectory != "" {
return s.options.RegistryDirectory
}
return filepath.Join(os.TempDir(), "fenrirscreenreader-instances")
}
func (s *Server) register() error {
dir := s.registry_dir()
if err := os.Mkdir(dir, os.ModeSticky|0777); err != nil && !os.IsExist(err) {
return err
}
info, err := os.Lstat(dir)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("invalid instance registry %s", dir)
}
// Only change permissions on a directory created/owned by this reader's UID.
if stat, ok := info.Sys().(*syscall.Stat_t); ok && int(stat.Uid) == os.Geteuid() {
if err := os.Chmod(dir, os.ModeSticky|0777); err != nil {
return err
}
info, err = os.Lstat(dir)
if err != nil {
return err
}
}
if info.Mode()&os.ModeSticky == 0 && info.Mode().Perm()&0022 != 0 {
return fmt.Errorf("instance registry lacks sticky permission: %s", dir)
}
now := float64(time.Now().UnixNano()) / 1e9
data := instance{ConnectionHandoff: true, PID: os.Getpid(), PPID: os.Getppid(), CreatedAt: now, UpdatedAt: now, ScreenDriver: "vcsaDriver", KeyboardDriver: "evdevDriver"}
if s.options.TerminalMode {
data.ScreenDriver, data.KeyboardDriver, data.WindowID = "ptyDriver", "x11Driver", s.options.WindowID
}
for _, endpoint := range s.endpoint_snapshot() {
data.SocketFiles = append(data.SocketFiles, endpoint.path)
data.MainSocket = data.MainSocket || endpoint.path == s.main_path()
}
encoded, err := json.Marshal(data)
if err != nil {
return err
}
file, err := os.CreateTemp(dir, ".instance-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err := file.Write(append(encoded, '\n')); err != nil {
file.Close()
return err
}
if err := file.Chmod(0644); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
path := filepath.Join(dir, strconv.Itoa(os.Getpid())+".json")
if err := os.Rename(file.Name(), path); err != nil {
return err
}
s.registryPath = path
return nil
}
func (s *Server) refresh_registry() {
ticker := time.NewTicker(200 * time.Millisecond)
lastRefresh := time.Now()
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if s.claim_main() || time.Since(lastRefresh) >= 10*time.Second {
_ = s.register()
lastRefresh = time.Now()
}
}
}
}
func (s *Server) instances() []instance {
entries, _ := os.ReadDir(s.registry_dir())
var instances []instance
for _, entry := range entries {
if s.ctx.Err() != nil {
break
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
path := filepath.Join(s.registry_dir(), entry.Name())
file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
continue
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
file.Close()
continue
}
var data instance
err = json.NewDecoder(io.LimitReader(file, 16384)).Decode(&data)
file.Close()
stat, ok := info.Sys().(*syscall.Stat_t)
if err != nil || !ok || data.PID <= 1 || entry.Name() != strconv.Itoa(data.PID)+".json" || time.Since(time.Unix(0, int64(data.UpdatedAt*1e9))) > 30*time.Second {
continue
}
if err := syscall.Kill(data.PID, 0); err != nil && err != syscall.EPERM {
continue
}
data.uid = int(stat.Uid)
instances = append(instances, data)
}
sort.Slice(instances, func(a, b int) bool { return instances[a].PID < instances[b].PID })
return instances
}
func (s *Server) list() string {
var lines []string
for _, data := range s.instances() {
window, main := data.WindowID, "no"
if window == "" {
window = "none"
}
if data.MainSocket {
main = "yes"
}
lines = append(lines, fmt.Sprintf("pid=%d ppid=%d screen=%s keyboard=%s main_socket=%s x11_window_id=%s sockets=%s", data.PID, data.PPID, data.ScreenDriver, data.KeyboardDriver, main, window, strings.Join(data.SocketFiles, ", ")))
}
if len(lines) == 0 {
return "No Fenrir instances registered"
}
return strings.Join(lines, "\n")
}
func parent_pid(pid int) int {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return 0
}
end := strings.LastIndexByte(string(data), ')')
if end < 0 {
return 0
}
fields := strings.Fields(string(data[end+1:]))
if len(fields) < 2 {
return 0
}
parent, _ := strconv.Atoi(fields[1])
return parent
}