diff --git a/.gitignore b/.gitignore index 50aa164..51f40cf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ coverage.out /cthulhu /cthulhu-braille +/cthulhu-input-helper /parityledger __pycache__/ *.py[cod] diff --git a/AGENTS.md b/AGENTS.md index 246e29b..da69dfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Use Go 1.24+ with CGO and development libraries listed by the Arch `PKGBUILD`. - `go build -o build/cthulhu ./cmd/cthulhu`: build the reader. - `go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille`: build the Braille helper. +- `go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper`: build the remote input helper; install it beside the reader for incoming keyboard control. - `go test ./... -count=1`: run default tests; native cases may skip. - `go test -tags brlapi ./... -count=1`: run packaging checks. - `go vet ./...`: check common Go errors. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 533b9de..c216654 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,12 +105,13 @@ Tentacles already use an external process protocol: preserve existing host safeg ### Local installation and reload -After building, install both Go binaries beside one another so the reader can -find its Braille helper: +After building, install all three Go binaries beside one another so the reader +can find its Braille and remote input helpers: ```sh install -Dm755 build/cthulhu "$HOME/.local/bin/cthulhu" install -Dm755 build/cthulhu-braille "$HOME/.local/bin/cthulhu-braille" +install -Dm755 build/cthulhu-input-helper "$HOME/.local/bin/cthulhu-input-helper" "$HOME/.local/bin/cthulhu" --check-config ``` diff --git a/README.md b/README.md index 04841dc..14ea2e7 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ AI-assisted contributions are welcome. Contributors remain responsible for human ## Building -Run `./build-cthulhu.sh` to build `cthulhu` and `cthulhu-braille` in the -checkout root. The script works from any directory. It uses Go's automatic -toolchain selection with Go 1.21 or newer. If Go is missing or older, it +Run `./build-cthulhu.sh` to build `cthulhu`, `cthulhu-braille`, and +`cthulhu-input-helper` in the checkout root. The script works from any +directory. It uses Go's automatic toolchain selection with Go 1.21 or newer. +If Go is missing or older, it downloads the version required by `go.mod`, verifies the archive's SHA-256, and caches it under `${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains`. It does not replace system Go or require root. @@ -38,7 +39,7 @@ Bootstrapping Go additionally needs `curl`, `tar`, and `sha256sum`. See [Contributing](CONTRIBUTING.md#development-and-architecture) and the Arch package recipes under `distro-packages/Arch-Linux/` for dependencies. -For packaging, put both binaries in a separate directory and pass build flags +For packaging, put all three binaries in a separate directory and pass build flags after `--`: ```sh @@ -58,6 +59,7 @@ that must use an installed toolchain: GOTOOLCHAIN=local GOPROXY=off ./build-cthulhu.sh --output-dir build -- -mod=readonly install -Dm755 build/cthulhu "$pkgdir/usr/bin/cthulhu" install -Dm755 build/cthulhu-braille "$pkgdir/usr/bin/cthulhu-braille" +install -Dm755 build/cthulhu-input-helper "$pkgdir/usr/bin/cthulhu-input-helper" ``` For offline builds, provision a suitable Go version and module cache in @@ -65,6 +67,12 @@ advance. Build inside the target distribution to link against its native libraries; downloading Go does not supply those libraries. `--help` lists the script's options without checking dependencies or accessing the network. +Keep all three executables together when installing or using a custom launch +script. `cthulhu-input-helper` receives remote keyboard input on Xorg. Without +it, Cthulhu can control another computer, but cannot accept its keyboard input. +The October 2026 release predates this helper and incoming Remote keyboard +support; packaging that release cannot enable the newer feature. + ## Start with the laptop keyboard layout Run `cthulhu -l` to use the laptop layout with Caps Lock as the Cthulhu diff --git a/build-cthulhu.sh b/build-cthulhu.sh index e08d258..2a7c971 100755 --- a/build-cthulhu.sh +++ b/build-cthulhu.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build both Cthulhu binaries without replacing the system Go installation. +# Build Cthulhu and its two helpers without replacing the system Go installation. set -euo pipefail projectDir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) @@ -15,11 +15,11 @@ Usage: build-cthulhu.sh [-o DIR | --output-dir DIR] [-- GO_BUILD_FLAGS...] Build cthulhu, cthulhu-braille and cthulhu-input-helper, obtaining a suitable Go toolchain if needed. - -o, --output-dir DIR Write both binaries to DIR (default: checkout root). + -o, --output-dir DIR Write all three binaries to DIR (default: checkout root). Relative paths start at the caller's directory. Missing directories are created. -h, --help Show help without checking build dependencies. - -- Pass remaining flags to both go build commands. + -- Pass remaining flags to all three go build commands. GOTOOLCHAIN defaults to auto. Set GOTOOLCHAIN=local to require installed Go and prevent toolchain downloads. Other Go settings, compiler/linker flags, diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 6e05c05..96ecb28 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -5,7 +5,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu pkgver=2026.10.31.r588.g9680b58 -pkgrel=1 +pkgrel=2 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" arch=(x86_64 aarch64 armv7h) @@ -84,7 +84,10 @@ build() { go build -o build/cthulhu ./cmd/cthulhu go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille - go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper + # Include the helper once incoming Remote keyboard support reaches master. + if [[ -d cmd/cthulhu-input-helper ]]; then + go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper + fi } check() { @@ -99,7 +102,9 @@ package() { cd "${_pkgname}" install -Dm755 build/cthulhu "${pkgdir}/usr/bin/cthulhu" install -Dm755 build/cthulhu-braille "${pkgdir}/usr/bin/cthulhu-braille" - install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper" + if [[ -d cmd/cthulhu-input-helper ]]; then + install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper" + fi install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 AUTHORS "${pkgdir}/usr/share/doc/${pkgname}/AUTHORS" } diff --git a/distro-packages/Arch-Linux/cthulhu/.SRCINFO b/distro-packages/Arch-Linux/cthulhu/.SRCINFO index 76ad8ed..8fb5aed 100644 --- a/distro-packages/Arch-Linux/cthulhu/.SRCINFO +++ b/distro-packages/Arch-Linux/cthulhu/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = cthulhu pkgdesc = Desktop-agnostic screen reader with speech, Braille, and Tentacle support pkgver = 2026.10.31 - pkgrel = 3 + pkgrel = 4 url = https://git.stormux.org/storm/cthulhu arch = x86_64 arch = aarch64 diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index 3f81fc0..641843f 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -3,7 +3,7 @@ pkgname=cthulhu pkgver=2026.10.31 -pkgrel=3 +pkgrel=4 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" arch=(x86_64 aarch64 armv7h) @@ -80,7 +80,10 @@ build() { go build -o build/cthulhu ./cmd/cthulhu go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille - go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper + # Older release tags predate incoming Remote keyboard support. + if [[ -d cmd/cthulhu-input-helper ]]; then + go build -o build/cthulhu-input-helper ./cmd/cthulhu-input-helper + fi } check() { @@ -95,7 +98,9 @@ package() { cd "${pkgname}" install -Dm755 build/cthulhu "${pkgdir}/usr/bin/cthulhu" install -Dm755 build/cthulhu-braille "${pkgdir}/usr/bin/cthulhu-braille" - install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper" + if [[ -d cmd/cthulhu-input-helper ]]; then + install -Dm755 build/cthulhu-input-helper "${pkgdir}/usr/bin/cthulhu-input-helper" + fi install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 AUTHORS "${pkgdir}/usr/share/doc/${pkgname}/AUTHORS" } diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index e7b9ffa..9e0a579 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -34,13 +34,18 @@ import ( // This uses the source-built external executable and real host/SDK IPC. Only // OpenLocal submissions are synthetic; it cannot prove physical-source input. func remote_command_fixture(t *testing.T, setup ...func(*tentacleRuntime)) (*tentacleRuntime, *consentTestForms, input.Command) { + t.Helper() + return remote_command_fixture_grants(t, []string{"output-observe", "remote-present"}, setup...) +} + +func remote_command_fixture_grants(t *testing.T, grants []string, setup ...func(*tentacleRuntime)) (*tentacleRuntime, *consentTestForms, input.Command) { t.Helper() program := os.Getenv("CTHULHU_REMOTE_BINARY") if program == "" { t.Skip("requires the separately built Remote executable") } directory := t.TempDir() - spec := tentacle.Spec{ID: "remote", Program: []string{program, "--data-dir", filepath.Join(directory, "state")}, Presentation: true, Interaction: true, SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present"}} + spec := tentacle.Spec{ID: "remote", Program: []string{program, "--data-dir", filepath.Join(directory, "state")}, Presentation: true, Interaction: true, SessionEvents: true, RemoteGrants: grants} data, _ := json.Marshal([]tentacle.Spec{spec}) launchPath := filepath.Join(directory, "launch.json") if err := os.WriteFile(launchPath, data, 0600); err != nil { @@ -130,7 +135,7 @@ func select_remote_relay(t *testing.T, forms *consentTestForms, address, role st return options } -func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentFormRequest, role string) consentFormRequest { +func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentFormRequest, role string, extra ...string) consentFormRequest { t.Helper() if !strings.Contains(consent.form.Title, "Remote access for remote") { t.Fatal("missing reader approval") @@ -144,7 +149,11 @@ func approve_remote_form(t *testing.T, forms *consentTestForms, consent consentF if role == "controlled" { grant = "output-observe" } - consent.reply <- internalUIResult{Activated: "approve", Values: map[string]interaction.Value{grant: {Checked: true}}} + values := map[string]interaction.Value{grant: {Checked: true}} + for _, name := range extra { + values[name] = interaction.Value{Checked: true} + } + consent.reply <- internalUIResult{Activated: "approve", Values: values} progress := next_remote_form(t, forms) if progress.form.Title != "Connecting Remote" { t.Fatal("approval did not start connection", progress.form.Title) @@ -289,8 +298,10 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { {"controller", "disconnect", true}, {"controller", "lock", true}, {"controller", "handoff", true}, {"controller", "disable", true}, {"controller", "backend-failure", true}, + {"controlled", "missing-helper", false}, } { role, ending := test.role, test.ending + keyboard := role == "controlled" && (ending == "disconnect" || ending == "missing-helper") name := role + "/" + ending if test.playback { name += "/production-speech" @@ -366,6 +377,18 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { } io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[{"id":7,"connection_type":"`+peerRole+`"}]}`+"\n") close(joined) + if keyboard { + select { + case <-speechReady: + case <-t.Context().Done(): + return + } + // NVDA and Cthulhu controllers use these same key frames. + for _, pressed := range []bool{true, false} { + json.NewEncoder(connection).Encode(map[string]any{"type": "key", "origin": 7, + "vk_code": 65, "extended": false, "pressed": pressed}) + } + } if role == "controller" { select { case <-speechReady: @@ -409,7 +432,18 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { }) }) } - runtime, forms, shortcut := remote_command_fixture(t, configure...) + grants := []string{"output-observe", "remote-present"} + var keyLog string + if keyboard { + grants = append(grants, "keyboard-inject") + helper, log := keyboard_owner_helper(t) + keyLog = log + if ending == "missing-helper" { + helper = filepath.Join(t.TempDir(), "absent") + } + configure = append(configure, func(runtime *tentacleRuntime) { runtime.remoteInputHelper = helper }) + } + runtime, forms, shortcut := remote_command_fixture_grants(t, grants, configure...) if role == "controlled" { worker := runtime.output.speech worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 64)}} @@ -459,7 +493,11 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { credentials.reply <- internalUIResult{Activated: "connect", Values: map[string]interaction.Value{"channel-key": {Text: "synthetic-fixture"}}} next_remote_form(t, forms) // Verified certificate progress. consent := next_remote_form(t, forms) - status := approve_remote_form(t, forms, consent, role) + var extra []string + if keyboard { + extra = append(extra, "keyboard-inject") + } + status := approve_remote_form(t, forms, consent, role, extra...) select { case <-joined: case <-time.After(5 * time.Second): @@ -507,6 +545,20 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { } } close(speechReady) + if ending == "missing-helper" { + remote_stream_wait(t, closed) + if _, err := os.Stat(keyLog); !os.IsNotExist(err) { + t.Fatal("missing helper unexpectedly delivered input", err) + } + return + } + if keyboard { + fixture := &keyboardOwnerFixture{log: keyLog} + fixture.wait(t, func() bool { + effects := fixture.effects() + return strings.Contains(effects, " a true\n") && strings.Contains(effects, " a false\n") + }) + } if ending == "backend-failure" { remote_stream_wait(t, closed) // Backend failure retires the lease, while the process and diff --git a/tests/test_build_script.py b/tests/test_build_script.py index 1b2da6b..723336f 100644 --- a/tests/test_build_script.py +++ b/tests/test_build_script.py @@ -42,7 +42,7 @@ elif tool in ("go", "bootstrap-go"): version = "1.24.0" if tool == "bootstrap-go" else os.environ["INSTALLED_GO"] print("go version go" + version + " linux/amd64") elif args[0] == "build": - if args[-1].endswith("cthulhu-braille") and os.environ.get("FAIL_BRAILLE"): + if args[-1] == os.environ.get("FAIL_BUILD_TARGET"): sys.exit(2) Path(args[args.index("-o") + 1]).touch() ''' @@ -162,11 +162,13 @@ class BuildScriptTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertFalse(self.calls("curl")) - def test_braille_failure_is_not_reported_as_success(self): - self.env["FAIL_BRAILLE"] = "1" - result = self.run_build() - self.assertNotEqual(result.returncode, 0) - self.assertNotIn("Cthulhu has been built", result.stdout) + def test_helper_failure_is_not_reported_as_success(self): + for helper in ("cthulhu-braille", "cthulhu-input-helper"): + with self.subTest(helper=helper): + self.env["FAIL_BUILD_TARGET"] = f"./cmd/{helper}" + result = self.run_build() + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Cthulhu has been built", result.stdout) def test_disabled_cgo_and_output_conflicts_fail_before_building(self): self.env["CGO_ENABLED"] = "0" diff --git a/tests/test_package_binaries.py b/tests/test_package_binaries.py new file mode 100644 index 0000000..e13cee9 --- /dev/null +++ b/tests/test_package_binaries.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Check package contents for sources before and after Remote input support.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +MOCK_GO = '''#!/usr/bin/env python3 +import os +from pathlib import Path +import sys +args = sys.argv[1:] +assert args[0] == "build" +assert Path(args[-1]).is_dir(), "build target is absent from this source version" +if args[-1] == os.environ.get("FAIL_BUILD_TARGET"): + sys.exit(2) +target = Path(args[args.index("-o") + 1]) +target.parent.mkdir(parents=True, exist_ok=True) +target.write_text("#!/bin/sh\\nexit 0\\n") +target.chmod(0o755) +''' + + +class PackageBinaryTests(unittest.TestCase): + def test_build_and_install_source_supported_helpers(self): + root = Path(__file__).resolve().parents[1] + recipes = sorted((root / "distro-packages").rglob("PKGBUILD")) + self.assertTrue(recipes) + for recipe in recipes: + for has_input, fail_input in ((False, False), (True, False), (True, True)): + with self.subTest(recipe=recipe.parent.name, has_input=has_input, + fail_input=fail_input), tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + source = work / "cthulhu" + binaries = ["cthulhu", "cthulhu-braille"] + if has_input: + binaries.append("cthulhu-input-helper") + for binary in binaries: + (source / "cmd" / binary).mkdir(parents=True) + for name in ("LICENSE", "AUTHORS"): + (source / name).write_text("fixture\n") + # A stale artifact must not enter an older source's package. + (source / "build").mkdir() + (source / "build" / "cthulhu-input-helper").write_text("stale") + tools = work / "tools" + tools.mkdir() + go = tools / "go" + go.write_text(MOCK_GO) + go.chmod(0o755) + stage = work / "package" + environment = dict(os.environ, PATH=f"{tools}:{os.environ['PATH']}", + srcdir=str(work), pkgdir=str(stage)) + if fail_input: + environment["FAIL_BUILD_TARGET"] = "./cmd/cthulhu-input-helper" + result = subprocess.run( + ["bash", "-ec", 'source "$1"\nbuild\ncd "$srcdir"\npackage', + "package-test", str(recipe)], cwd=work, env=environment, + capture_output=True, text=True, check=False) + if fail_input: + self.assertNotEqual(result.returncode, 0) + self.assertFalse(stage.exists()) + continue + self.assertEqual(result.returncode, 0, result.stderr) + installed = stage / "usr/bin" + self.assertEqual(sorted(p.name for p in installed.iterdir()), sorted(binaries)) + for binary in binaries: + self.assertTrue(os.access(installed / binary, os.X_OK)) + + +if __name__ == "__main__": + unittest.main()