diff --git a/README.md b/README.md index 4101d71..6cdfa6f 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,13 @@ Helpers built before identity reporting do not provide these identity/status records. The installed helper's `--version` option can be used for a manual check; it does not require a running reader. +`cthulhu -v`, `cthulhu --version`, and **Cthulhu+Shift+V** append +`wine-access YYYY.MM.DD` when the selected installed helper contains release +metadata. This reads its `.exe.so` sidecar directly without starting Wine. +Rebuild Wine Access to supply the metadata; older helpers are omitted. +The reported release describes the installed files, which can differ from a +helper still running after an upgrade. + Helper-prefixed stderr diagnostics are captured with a 4096-byte line limit, a 64-record queue and up to 32 distinct records per ten-second window. Repeated, oversized and excess records are counted in a suppression notice. Unrelated diff --git a/cmd/cthulhu/main.go b/cmd/cthulhu/main.go index 9712092..2671602 100644 --- a/cmd/cthulhu/main.go +++ b/cmd/cthulhu/main.go @@ -43,6 +43,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/speechhistoryui" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/version" + "git.stormux.org/storm/cthulhu/internal/wine" ) func main() { @@ -95,6 +96,7 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res atspi.Version(), os.Getenv("XDG_SESSION_TYPE"), os.Getenv("XDG_SESSION_DESKTOP"), + wine.InstalledVersion(), )) fmt.Fprintln(stdout) fmt.Fprintln(stdout, "Usage: cthulhu [options]") @@ -132,6 +134,7 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res atspi.Version(), os.Getenv("XDG_SESSION_TYPE"), os.Getenv("XDG_SESSION_DESKTOP"), + wine.InstalledVersion(), )) return nil } diff --git a/cmd/cthulhu/main_test.go b/cmd/cthulhu/main_test.go index 215f84b..0d6060d 100644 --- a/cmd/cthulhu/main_test.go +++ b/cmd/cthulhu/main_test.go @@ -26,21 +26,22 @@ import ( "strings" "testing" + "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/version" + "git.stormux.org/storm/cthulhu/internal/wine" ) 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") + want := version.Details(atspi.Version(), "x11", "GNOME", wine.InstalledVersion()) 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) + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("version output = %q, want %q", got, want) } } } diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 0483c67..ba8b8e8 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.23.r73.g03e2b31 +pkgver=2026.10.24.r75.g8a560a2 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/internal/app/version.go b/internal/app/version.go index 0afeb53..30b8fa0 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -4,11 +4,14 @@ package app -import "git.stormux.org/storm/cthulhu/internal/version" +import ( + "git.stormux.org/storm/cthulhu/internal/version" + "git.stormux.org/storm/cthulhu/internal/wine" +) func presentVersion(speech speechEnqueuer, atspiVersion, sessionType, sessionDesktop string) { speech.Enqueue(speechRequest{ - Text: version.Details(atspiVersion, sessionType, sessionDesktop), + Text: version.Details(atspiVersion, sessionType, sessionDesktop, wine.InstalledVersion()), Role: "system", Cause: "version", }) diff --git a/internal/app/version_test.go b/internal/app/version_test.go index 61e2abd..c1ece08 100644 --- a/internal/app/version_test.go +++ b/internal/app/version_test.go @@ -8,6 +8,7 @@ import ( "testing" "git.stormux.org/storm/cthulhu/internal/version" + "git.stormux.org/storm/cthulhu/internal/wine" ) func TestPresentVersion(t *testing.T) { @@ -23,7 +24,7 @@ func TestPresentVersion(t *testing.T) { default: } request := <-speech.requests - want := version.Details("2.58.0", "x11", "GNOME") + want := version.Details("2.58.0", "x11", "GNOME", wine.InstalledVersion()) if request.Text != want || request.Role != "system" || request.Cause != "version" { t.Fatalf("request = %+v, want %q as system version", request, want) } diff --git a/internal/version/version.go b/internal/version/version.go index e888f50..26e507a 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -30,11 +30,14 @@ func String() string { return Name + " screen reader version " + Build + "-" + Channel } -func Details(atspiVersion, sessionType, sessionDesktop string) string { +func Details(atspiVersion, sessionType, sessionDesktop, wineVersion string) string { details := String() + ", AT-SPI2 version: " + atspiVersion session := strings.TrimSpace(sessionType + " " + sessionDesktop) if session != "" { details += ", Session: " + session } + if wineVersion != "" { + details += ", wine-access " + wineVersion + } return details } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 24b124c..6e543b0 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -42,12 +42,22 @@ func TestStringUsesCurrentVersionByDefault(t *testing.T) { } func TestDetailsIncludesATSPIAndSession(t *testing.T) { - got := Details("2.58.0", "x11", "GNOME") + got := Details("2.58.0", "x11", "GNOME", "") want := "Cthulhu screen reader version " + Version + "-" + Channel + ", AT-SPI2 version: 2.58.0, Session: x11 GNOME" if got != want { t.Fatalf("Details() = %q, want %q", got, want) } - if Details("2.58.0", " ", "") != strings.TrimSuffix(got, ", Session: x11 GNOME") { + if Details("2.58.0", " ", "", "") != strings.TrimSuffix(got, ", Session: x11 GNOME") { t.Fatal("empty session information was included") } } + +func TestDetailsIncludesOptionalWineAccess(t *testing.T) { + base := Details("2.58.0", "x11", "GNOME", "") + if got := Details("2.58.0", "x11", "GNOME", "2026.10.24"); got != base+", wine-access 2026.10.24" { + t.Fatalf("Details() = %q", got) + } + if got := Details("2.58.0", "x11", "GNOME", ""); got != base { + t.Fatalf("missing helper changed details: %q", got) + } +} diff --git a/internal/wine/version.go b/internal/wine/version.go new file mode 100644 index 0000000..f75920a --- /dev/null +++ b/internal/wine/version.go @@ -0,0 +1,40 @@ +package wine + +import ( + "debug/elf" + "path/filepath" + "time" +) + +// InstalledVersion reads the selected helper's embedded release without +// executing Wine. Older helpers without the metadata return an empty string. +// This describes the files on disk, which may differ from a running helper. +func InstalledVersion() string { + helper := helperPath() + if helper == "" { + return "" + } + // Resolve launcher symlinks before locating its adjacent Winelib sidecar. + helper, err := filepath.EvalSymlinks(helper) + if err != nil { + return "" + } + file, err := elf.Open(helper + ".so") + if err != nil { + return "" + } + defer file.Close() + section := file.Section(".wine_access.version") + if section == nil || section.Size != 11 || section.Flags&elf.SHF_COMPRESSED != 0 { + return "" + } + var data [11]byte + if _, err := section.ReadAt(data[:], 0); err != nil || data[10] != 0 { + return "" + } + release := string(data[:10]) + if _, err := time.Parse("2006.01.02", release); err != nil { + return "" + } + return release +} diff --git a/internal/wine/version_test.go b/internal/wine/version_test.go new file mode 100644 index 0000000..5ebf3d6 --- /dev/null +++ b/internal/wine/version_test.go @@ -0,0 +1,65 @@ +package wine + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestInstalledVersion(t *testing.T) { + root := t.TempDir() + helper := filepath.Join(root, "wine-access.exe") + // The launcher must never run, including for old helpers lacking metadata. + marker := filepath.Join(root, "executed") + if err := os.WriteFile(helper, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 99\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", helper) + for _, test := range []struct { + name, declaration, want string + }{ + {"current", `const char release[] = "2026.10.24";`, "2026.10.24"}, + {"old helper", `int unused;`, ""}, + {"invalid date", `const char release[] = "2026.99.99";`, ""}, + {"oversized", `const char release[] = "2026.10.24 extra";`, ""}, + {"unterminated", `const char release[11] = "2026.10.24x";`, ""}, + } { + t.Run(test.name, func(t *testing.T) { + source := "__attribute__((used, section(\".wine_access.version\")))\n" + test.declaration + if test.name == "old helper" { + source = test.declaration + } + command := exec.Command("cc", "-shared", "-fPIC", "-x", "c", "-o", helper+".so", "-") + command.Stdin = strings.NewReader(source) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("compile helper metadata fixture: %v\n%s", err, output) + } + if got := InstalledVersion(); got != test.want { + t.Fatalf("InstalledVersion() = %q, want %q", got, test.want) + } + link := filepath.Join(t.TempDir(), "wine-access.exe") + if err := os.Symlink(helper, link); err != nil { + t.Fatal(err) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", link) + if got := InstalledVersion(); got != test.want { + t.Fatalf("symlink InstalledVersion() = %q, want %q", got, test.want) + } + }) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("version detection executed the helper: %v", err) + } + if err := os.WriteFile(helper+".so", []byte("broken sidecar"), 0600); err != nil { + t.Fatal(err) + } + if got := InstalledVersion(); got != "" { + t.Fatalf("invalid ELF returned %q", got) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "/usr/bin/true") + if got := InstalledVersion(); got != "" { + t.Fatalf("missing sidecar returned %q", got) + } +}