Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+33
View File
@@ -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)
}
}
+227
View File
@@ -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
}
+227
View File
@@ -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)
}
}
+149
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+65
View File
@@ -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())
}
+47
View File
@@ -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())
}
}
+84
View File
@@ -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()
}
}