Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,33 @@
|
||||
// cthulhu-braille is the isolated BrlAPI helper and standalone diagnostic.
|
||||
// The main executable starts it only when braille is enabled.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 2 || len(os.Args) == 2 && os.Args[1] != "--client" {
|
||||
fmt.Fprintln(os.Stderr, "Usage: cthulhu-braille [--client]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if len(os.Args) == 2 {
|
||||
if err := braille.RunClient(ctx, braille.NewRouting(), os.Stdin, os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := braille.Diagnostic(ctx, braille.New(), os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
//
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/app"
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/clipboard"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/logging"
|
||||
"git.stormux.org/storm/cthulhu/internal/notificationui"
|
||||
"git.stormux.org/storm/cthulhu/internal/ocrcapture"
|
||||
"git.stormux.org/storm/cthulhu/internal/ocrconfirm"
|
||||
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||
"git.stormux.org/storm/cthulhu/internal/shortcutui"
|
||||
"git.stormux.org/storm/cthulhu/internal/speechhistoryui"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cthulhu: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (result error) {
|
||||
options := flag.NewFlagSet("cthulhu", flag.ContinueOnError)
|
||||
options.SetOutput(stderr)
|
||||
tentacleFile := options.String("tentacles", "", "explicit local tentacle launch list (experimental JSON)")
|
||||
tentacleParent := options.String("tentacle-cgroup-parent", "auto", "delegated cgroup v2 parent for local tentacles, or auto")
|
||||
noTentacles := options.Bool("no-tentacles", false, "start without tentacles, ignoring saved tentacles and the local launch list")
|
||||
tentacleGuardian := options.String("tentacle-guardian", "", "run the internal tentacle lifetime helper")
|
||||
showVersion := options.Bool("version", false, "print version and exit")
|
||||
shortVersion := options.Bool("v", false, "print version and exit")
|
||||
replace := options.Bool("replace", false, "stop the running Cthulhu instance before starting")
|
||||
shortReplace := options.Bool("r", false, "stop the running Cthulhu instance before starting")
|
||||
checkConfig := options.Bool("check-config", false, "validate configuration and exit")
|
||||
shortSettings := options.Bool("s", false, "open the preferences window at startup")
|
||||
settingsFlag := options.Bool("settings", false, "open the preferences window at startup")
|
||||
preferencesUI := options.Bool("preferences-ui", false, "run the internal preferences helper (private pipe protocol)")
|
||||
notificationListUI := options.Bool("notification-list-ui", false, "run the internal notification list helper (private pipe protocol)")
|
||||
speechHistoryUI := options.Bool("speech-history-ui", false, "run the internal speech history helper (private pipe protocol)")
|
||||
shortcutListUI := options.Bool("shortcut-list-ui", false, "run the internal shortcut list helper (private pipe protocol)")
|
||||
clipboardRead := options.Bool("clipboard-read", false, "run the internal clipboard reader (private pipe protocol)")
|
||||
clipboardWatch := options.Bool("clipboard-watch", false, "run the internal clipboard change helper (private pipe protocol)")
|
||||
ocrCapture := options.Bool("ocr-capture", false, "run the internal OCR capture helper (private pipe protocol)")
|
||||
ocrConfirm := options.Bool("ocr-confirm", false, "run the internal OCR click confirmation helper")
|
||||
debugFile := options.String("debug-file", "", "enable debug logging and append logs only to this file")
|
||||
speechSocket := options.String(
|
||||
"speech-socket",
|
||||
"",
|
||||
"Speech Dispatcher SSIP socket (overrides SPEECHD_ADDRESS and SPEECHD_SOCKET)",
|
||||
)
|
||||
configPath := options.String(
|
||||
"config",
|
||||
"",
|
||||
"TOML configuration file (default: $XDG_DATA_HOME/cthulhu/user-settings-go.toml)",
|
||||
)
|
||||
options.Usage = func() {
|
||||
fmt.Fprintln(stdout, version.Details(
|
||||
atspi.Version(),
|
||||
os.Getenv("XDG_SESSION_TYPE"),
|
||||
os.Getenv("XDG_SESSION_DESKTOP"),
|
||||
))
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Usage: cthulhu [options]")
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Options:")
|
||||
// Helpers remain parseable but are not part of the public help.
|
||||
visibleOptions := flag.NewFlagSet("cthulhu", flag.ContinueOnError)
|
||||
visibleOptions.SetOutput(stderr)
|
||||
options.VisitAll(func(option *flag.Flag) {
|
||||
switch option.Name {
|
||||
case "preferences-ui", "notification-list-ui", "speech-history-ui", "shortcut-list-ui",
|
||||
"clipboard-read", "clipboard-watch", "ocr-capture", "ocr-confirm", "tentacle-guardian":
|
||||
return
|
||||
}
|
||||
visibleOptions.Var(option.Value, option.Name, option.Usage)
|
||||
visibleOptions.Lookup(option.Name).DefValue = option.DefValue
|
||||
})
|
||||
visibleOptions.PrintDefaults()
|
||||
}
|
||||
|
||||
if err := options.Parse(arguments); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("parse command line: %w", err)
|
||||
}
|
||||
if options.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected argument %q", options.Arg(0))
|
||||
}
|
||||
if *showVersion || *shortVersion {
|
||||
fmt.Fprintln(stdout, version.Details(
|
||||
atspi.Version(),
|
||||
os.Getenv("XDG_SESSION_TYPE"),
|
||||
os.Getenv("XDG_SESSION_DESKTOP"),
|
||||
))
|
||||
return nil
|
||||
}
|
||||
if *preferencesUI {
|
||||
return preferences.Run(ctx, os.Stdin, stdout)
|
||||
}
|
||||
if *tentacleGuardian != "" {
|
||||
stopInput := context.AfterFunc(ctx, func() { os.Stdin.Close() })
|
||||
defer stopInput()
|
||||
return tentacle.RunGuardian(os.Stdin, stdout, *tentacleGuardian)
|
||||
}
|
||||
if *ocrConfirm {
|
||||
return ocrconfirm.Run(os.Stdin, stdout)
|
||||
}
|
||||
if *notificationListUI {
|
||||
return notificationui.Run(ctx, os.Stdin, stdout)
|
||||
}
|
||||
if *shortcutListUI {
|
||||
return shortcutui.Run(os.Stdin)
|
||||
}
|
||||
if *speechHistoryUI {
|
||||
return speechhistoryui.Run(ctx, os.Stdin, stdout)
|
||||
}
|
||||
if *clipboardRead {
|
||||
return clipboard.WriteMessage(stdout)
|
||||
}
|
||||
if *clipboardWatch {
|
||||
return clipboard.WatchText(ctx, os.Stdin, stdout)
|
||||
}
|
||||
if *ocrCapture {
|
||||
return ocrcapture.Run(ctx, os.Stdin, stdout)
|
||||
}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
if *debugFile != "" {
|
||||
fileWriter, err := logging.OpenFile(*debugFile, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open debug log: %w", err)
|
||||
}
|
||||
diagnostics := logging.NewAsync(fileWriter, logging.AsyncOptions{
|
||||
Options: logging.Options{Level: slog.LevelDebug, AddSource: true},
|
||||
})
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := diagnostics.Stop(stopContext); err != nil {
|
||||
result = errors.Join(result, fmt.Errorf("stop diagnostics: %w", err))
|
||||
}
|
||||
}()
|
||||
logger = diagnostics.Logger()
|
||||
}
|
||||
logger.Info("starting Cthulhu", "version", version.String(), "pid", os.Getpid())
|
||||
settings, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("load configuration", "error", err)
|
||||
return err
|
||||
}
|
||||
if *checkConfig {
|
||||
if settings.Loaded && settings.ActiveProfile != "" {
|
||||
fmt.Fprintf(stdout, "Configuration is valid; active profile: %s.\n", settings.ActiveProfile)
|
||||
} else if settings.Loaded {
|
||||
fmt.Fprintln(stdout, "Configuration is valid.")
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "Configuration file not found; built-in defaults are valid.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if *replace || *shortReplace {
|
||||
if err := replaceRunning(ctx); err != nil {
|
||||
return fmt.Errorf("replace running Cthulhu: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.Loaded {
|
||||
logger.Info("loaded configuration", "profile", settings.ActiveProfile,
|
||||
"keyboard_layout", settings.KeyboardLayout, "cthulhu_modifier_keys", settings.CthulhuModifierKeys)
|
||||
} else {
|
||||
logger.Info("configuration not found; using defaults")
|
||||
}
|
||||
registryPath := tentacle.RegistryPath(settings.Path)
|
||||
if *noTentacles {
|
||||
*tentacleFile = ""
|
||||
registryPath = ""
|
||||
}
|
||||
err = app.Run(ctx, app.Options{
|
||||
TentacleFile: *tentacleFile,
|
||||
TentacleRegistry: registryPath,
|
||||
TentacleCgroupParent: *tentacleParent,
|
||||
EnableRemote: true,
|
||||
OpenPreferences: *shortSettings || *settingsFlag,
|
||||
Logger: logger,
|
||||
SpeechSocket: *speechSocket,
|
||||
Settings: &settings,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Cthulhu stopped with an error", "error", err)
|
||||
} else {
|
||||
logger.Info("Cthulhu stopped")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
//
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/version"
|
||||
)
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
t.Setenv("XDG_SESSION_TYPE", "x11")
|
||||
t.Setenv("XDG_SESSION_DESKTOP", "GNOME")
|
||||
want := version.Details("2.58.0", "x11", "GNOME")
|
||||
for _, argument := range []string{"--version", "-v"} {
|
||||
var stdout bytes.Buffer
|
||||
if err := run(context.Background(), []string{argument}, &stdout, &stdout); err != nil {
|
||||
t.Fatalf("run %s: %v", argument, err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); !strings.Contains(got, "Cthulhu screen reader version") ||
|
||||
!strings.Contains(got, "AT-SPI2 version:") || !strings.Contains(got, "Session: x11 GNOME") {
|
||||
t.Fatalf("version output = %q, want details including %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelp(t *testing.T) {
|
||||
var versionOutput bytes.Buffer
|
||||
if err := run(context.Background(), []string{"-v"}, &versionOutput, &versionOutput); err != nil {
|
||||
t.Fatalf("run -v: %v", err)
|
||||
}
|
||||
for _, argument := range []string{"-h", "--help"} {
|
||||
var stdout bytes.Buffer
|
||||
if err := run(context.Background(), []string{argument}, &stdout, &stdout); err != nil {
|
||||
t.Fatalf("run %s: %v", argument, err)
|
||||
}
|
||||
help := stdout.String()
|
||||
if strings.Contains(help, "tentacle-guardian") {
|
||||
t.Fatal("private guardian exposed in help")
|
||||
}
|
||||
if !strings.HasPrefix(help, versionOutput.String()+"\nUsage: cthulhu [options]\n\nOptions:\n") ||
|
||||
!strings.Contains(help, "-s") || !strings.Contains(help, "-settings") ||
|
||||
!strings.Contains(help, "-r") || !strings.Contains(help, "-replace") {
|
||||
t.Fatalf("help output does not contain expected version, usage, and flags: %q", help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "user-settings.toml")
|
||||
contents := []byte("[general]\nstartingProfile = [\"Default\", \"default\"]\n[profiles.default]\nspeakDescription = false\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := run(
|
||||
context.Background(),
|
||||
[]string{"--check-config", "--config", path},
|
||||
&stdout,
|
||||
&stderr,
|
||||
); err != nil {
|
||||
t.Fatalf("run --check-config: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "active profile: default") {
|
||||
t.Fatalf("check-config output = %q", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("logging enabled without --debug-file: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckVersionedConfig(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
contents string
|
||||
wantError bool
|
||||
}{
|
||||
{"current", "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\nenabled = false\n", false},
|
||||
{"future", "schema-version = 3\nactive-profile = 'default'\n[profiles.default]\n", true},
|
||||
{"invalid", "schema-version = 2\nactive-profile = 'default'\n[profiles.default.sound]\nvolume = 2.0\n", true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.toml")
|
||||
if err := os.WriteFile(path, []byte(test.contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := run(context.Background(), []string{"--check-config", "--config", path}, &stdout, &stderr)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("check-config error = %v, want error %v", err, test.wantError)
|
||||
}
|
||||
if !test.wantError && stdout.String() != "Configuration is valid; active profile: default.\n" {
|
||||
t.Fatalf("check-config output = %q", stdout.String())
|
||||
}
|
||||
if test.wantError && stdout.Len() != 0 {
|
||||
t.Fatalf("invalid configuration reported success: %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("logging enabled without --debug-file: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugFileCapturesStartupFailureAndAppends(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
logPath := filepath.Join(directory, "debug.log")
|
||||
configPath := filepath.Join(directory, "settings.toml")
|
||||
configContents := []byte("[profiles.default]\nenableSound = false\n")
|
||||
if err := os.WriteFile(configPath, configContents, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
for range 2 {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := run(context.Background(), []string{
|
||||
"--debug-file", logPath,
|
||||
"--config", configPath,
|
||||
"--speech-socket", filepath.Join(directory, "absent.sock"),
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("startup unexpectedly succeeded")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connect to Speech Dispatcher") {
|
||||
t.Fatalf("startup error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("debug-file wrote to terminal: stdout %q, stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(string(data), `"message":"starting Cthulhu"`) != 2 {
|
||||
t.Fatalf("previous session was not retained: %s", data)
|
||||
}
|
||||
if strings.Count(string(data), `"message":"Cthulhu stopped with an error"`) != 2 {
|
||||
t.Fatalf("startup failures were not logged: %s", data)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["source"] == nil {
|
||||
t.Fatalf("debug-file did not enable sources: %s", line)
|
||||
}
|
||||
if record["level"] == "ERROR" {
|
||||
message, ok := record["error"].(string)
|
||||
if !ok || !strings.Contains(message, "connect to Speech Dispatcher") {
|
||||
t.Fatalf("missing actual error: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("log permissions = %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugFileOpenFailure(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
err := run(context.Background(), []string{"--debug-file", t.TempDir()}, &output, &output)
|
||||
if err == nil || !strings.Contains(err.Error(), "open debug log") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugFileCheckConfig(t *testing.T) {
|
||||
logPath := filepath.Join(t.TempDir(), "debug.log")
|
||||
configPath := filepath.Join(t.TempDir(), "absent.toml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := run(context.Background(), []string{
|
||||
"--check-config", "--config", configPath, "--debug-file", logPath,
|
||||
}, &stdout, &stderr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stdout.String() != "Configuration file not found; built-in defaults are valid.\n" {
|
||||
t.Fatalf("check-config output = %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("debug-file wrote to terminal: %q", stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(data), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["message"] != "starting Cthulhu" || record["source"] == nil {
|
||||
t.Fatalf("missing startup diagnostic or source: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneDebugFlagRejected(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := run(context.Background(), []string{"--debug"}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -debug") {
|
||||
t.Fatalf("standalone --debug error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const replaceWait = 8 * time.Second
|
||||
|
||||
// replaceRunning identifies the active Go reader through its private session
|
||||
// bus name. It never signals a process merely because its name matches.
|
||||
func replaceRunning(ctx context.Context) error {
|
||||
connection, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to session bus: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
owner, err := busNameOwner(ctx, connection)
|
||||
if err != nil || owner == "" {
|
||||
return err
|
||||
}
|
||||
var pid uint32
|
||||
if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixProcessID", 0, owner).Store(&pid); err != nil {
|
||||
return fmt.Errorf("get running reader PID: %w", err)
|
||||
}
|
||||
var uid uint32
|
||||
if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixUser", 0, owner).Store(&uid); err != nil {
|
||||
return fmt.Errorf("get running reader UID: %w", err)
|
||||
}
|
||||
if uid != uint32(os.Getuid()) || pid == 0 || int(pid) == os.Getpid() {
|
||||
return errors.New("running reader has an unexpected owner or PID")
|
||||
}
|
||||
if err := verifyReaderExecutable(int(pid)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, started, err := processIdentity(int(pid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect running reader identity: %w", err)
|
||||
}
|
||||
if current, err := busNameOwner(ctx, connection); err != nil || current != owner {
|
||||
return fmt.Errorf("running reader changed before replacement: %w", err)
|
||||
}
|
||||
if err := syscall.Kill(int(pid), syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("stop running reader: %w", err)
|
||||
}
|
||||
if err := waitForProcessExit(ctx, int(pid), started, replaceWait); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
// The old reader did not finish shutdown. Verify the PID still belongs to
|
||||
// a reader before forcing it down.
|
||||
if _, currentStart, err := processIdentity(int(pid)); errors.Is(err, os.ErrNotExist) || err == nil && currentStart != started {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyReaderExecutable(int(pid)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Kill(int(pid), syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return fmt.Errorf("force stop running reader: %w", err)
|
||||
}
|
||||
return waitForProcessExit(ctx, int(pid), started, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForProcessExit(ctx context.Context, pid int, started string, timeout time.Duration) error {
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
state, currentStart, err := processIdentity(pid)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect running reader state: %w", err)
|
||||
}
|
||||
if state == "Z" || currentStart != started {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return context.DeadlineExceeded
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processIdentity(pid int) (state, started string, err error) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
closing := strings.LastIndexByte(string(data), ')')
|
||||
if closing < 0 {
|
||||
return "", "", errors.New("invalid process status")
|
||||
}
|
||||
fields := strings.Fields(string(data[closing+1:]))
|
||||
if len(fields) <= 19 {
|
||||
return "", "", errors.New("incomplete process status")
|
||||
}
|
||||
return fields[0], fields[19], nil
|
||||
}
|
||||
|
||||
func busNameOwner(ctx context.Context, connection *dbus.Conn) (string, error) {
|
||||
var owner string
|
||||
err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetNameOwner", 0, remote.BusName).Store(&owner)
|
||||
if err == nil {
|
||||
return owner, nil
|
||||
}
|
||||
var busError dbus.Error
|
||||
if errors.As(err, &busError) && busError.Name == "org.freedesktop.DBus.Error.NameHasNoOwner" {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("query running reader: %w", err)
|
||||
}
|
||||
|
||||
func verifyReaderExecutable(pid int) error {
|
||||
running, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect running reader: %w", err)
|
||||
}
|
||||
current, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect current reader: %w", err)
|
||||
}
|
||||
running = strings.TrimSuffix(running, " (deleted)")
|
||||
if filepath.Base(running) != filepath.Base(current) {
|
||||
return fmt.Errorf("D-Bus owner is %q, not %q", running, filepath.Base(current))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func TestReplaceHelper(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_REPLACE_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
connection, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
reply, err := connection.RequestName(remote.BusName, dbus.NameFlagDoNotQueue)
|
||||
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
t.Fatalf("own reader bus name: %v, %v", reply, err)
|
||||
}
|
||||
fmt.Println("READY")
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
<-signals
|
||||
}
|
||||
|
||||
func TestReplaceRunningNative(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_REPLACE") != "1" {
|
||||
t.Skip("requires isolated session bus")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := replaceRunning(ctx); err != nil {
|
||||
t.Fatalf("replace with no reader: %v", err)
|
||||
}
|
||||
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestReplaceHelper$")
|
||||
command.Env = append(os.Environ(), "CTHULHU_REPLACE_HELPER=1")
|
||||
output, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if command.ProcessState == nil {
|
||||
command.Process.Kill()
|
||||
command.Wait()
|
||||
}
|
||||
}()
|
||||
ready := make(chan bool, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(output)
|
||||
for scanner.Scan() {
|
||||
if scanner.Text() == "READY" {
|
||||
ready <- true
|
||||
return
|
||||
}
|
||||
}
|
||||
ready <- false
|
||||
}()
|
||||
select {
|
||||
case started := <-ready:
|
||||
if !started {
|
||||
t.Fatal("helper did not claim bus name")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("helper did not claim bus name")
|
||||
}
|
||||
if err := replaceRunning(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := command.Wait(); err != nil {
|
||||
t.Fatalf("running reader did not exit cleanly: %v", err)
|
||||
}
|
||||
if err := replaceRunning(ctx); err != nil {
|
||||
t.Fatalf("replace after reader exit: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
//
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/parity"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "parityledger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string, stdout, stderr io.Writer) error {
|
||||
if len(arguments) == 1 && (arguments[0] == "-h" || arguments[0] == "--help") {
|
||||
writeUsage(stdout)
|
||||
return nil
|
||||
}
|
||||
if len(arguments) != 2 || arguments[0] != "validate" {
|
||||
writeUsage(stderr)
|
||||
return fmt.Errorf("expected validate LEDGER")
|
||||
}
|
||||
|
||||
file, err := os.Open(arguments[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("open parity ledger: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
records, err := parity.Load(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate parity ledger: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Validated %d parity ledger rows.\n", len(records))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "Usage: parityledger validate LEDGER")
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "Validates the schema, required fields, statuses, and duplicate row IDs.")
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintf(writer, "Allowed statuses: %s.\n", parity.FormatStatuses())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
//
|
||||
// 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 main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateFixture(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
ledgerPath := filepath.Join("..", "..", "testdata", "parity-ledger.csv")
|
||||
if err := run([]string{"validate", ledgerPath}, &stdout, &stdout); err != nil {
|
||||
t.Fatalf("validate fixture: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(stdout.String(), "Validated ") ||
|
||||
!strings.Contains(stdout.String(), " parity ledger rows.") {
|
||||
t.Fatalf("unexpected validation output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsage(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
if err := run([]string{"--help"}, &stdout, &stdout); err != nil {
|
||||
t.Fatalf("run --help: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Usage: parityledger validate LEDGER") {
|
||||
t.Fatalf("unexpected usage output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// A host diagnostic tool for external Tentacles, independent of the running reader.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() (runError error) {
|
||||
options := flag.NewFlagSet("host", flag.ContinueOnError)
|
||||
cgroupParent := options.String("cgroup-parent", "", "delegated cgroup v2 parent for resource containment")
|
||||
presentation := options.Bool("presentation", false, "grant command speech/braille result capability (prints JSON only)")
|
||||
interactive := options.Bool("interaction", false, "grant internal form/clipboard/focused-item capability (prints initial JSON only)")
|
||||
command := options.String("command", "echo", "registered command to invoke")
|
||||
if err := options.Parse(os.Args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.NArg() < 1 {
|
||||
return fmt.Errorf("usage: host [-cgroup-parent /delegated/parent] /absolute/program [arguments...]")
|
||||
}
|
||||
var manager *tentacle.Manager
|
||||
var err error
|
||||
if *cgroupParent == "" {
|
||||
manager, err = tentacle.NewManager(tentacle.DefaultLimits(), nil)
|
||||
} else {
|
||||
manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, *cgroupParent, tentacle.DefaultResourceLimits())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
runError = errors.Join(runError, manager.Shutdown(ctx))
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
session, err := manager.Start(ctx, tentacle.Spec{ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-session.Ready():
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
if state, err := session.Status(); state != tentacle.Ready {
|
||||
return fmt.Errorf("startup %s: %v", state, err)
|
||||
}
|
||||
if err := session.Activate(); err != nil {
|
||||
return err
|
||||
}
|
||||
pending, err := session.Invoke(ctx, *command, json.RawMessage(`{"text":"Hello, é猫😀é"}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case result := <-pending:
|
||||
if result.Err != nil {
|
||||
return result.Err
|
||||
}
|
||||
fmt.Println(string(result.Value))
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user