7 Commits
49 changed files with 1717 additions and 89 deletions

No files matched your search

+2
View File
@@ -14,3 +14,5 @@ __pycache__/
/distro-packages/**/*.pkg.tar.zst
/distro-packages/**/*.pkg.tar.xz
/distro-packages/Arch-Linux/cthulhu-git/*/
/cmd/cthulhu/cthulhu
+4
View File
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification.
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
- [XTerm handoff](docs/testing/xterm-handoff.md): terminal reader keys, lock state, speech interruption, and verification.
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
@@ -15,6 +16,7 @@ Cthulhu is a Linux screen reader written in Go.
- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification.
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification.
- [Tentacle updates](docs/testing/tentacle-updates.md): package recovery, version comparison, and update verification.
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
@@ -314,6 +316,8 @@ allows a successful virtual reading move.
## Wine Access
Install the separate `wine-access` helper for Wine application accessibility.
See [Steam/Proton and controller-only verification](docs/testing/wine-proton.md)
for loader support, container staging, hook exclusions, and live regression checks.
Cthulhu checks `CTHULHU_WINE_ACCESS_HELPER`, then
`~/.local/libexec/wine-access/wine-access.exe`,
`/usr/local/libexec/wine-access/wine-access.exe`,
Binary file not shown.
+1 -1
View File
@@ -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)
+6 -4
View File
@@ -9,7 +9,7 @@ the same, with a Braille flash through the system-role output queue.
The motivating provider is the Niri compositor (25.08+), which exposes its
own UI through AccessKit. A capture from Niri 26.04 on 2026-10-31 shows a
workspace switch emits `object:property-change:accessible-name` and then
`object:announcement` (polite) with text such as `Workspace 2 Mistworld` on
`object:announcement` (polite) with text such as `Workspace 2 Example` on
a label in the `niri` application, about 10 ms before the newly focused
window activates. Niri alternates live-node identities (and may append a
space) so repeated messages still emit; Cthulhu trims the payload and
@@ -18,7 +18,8 @@ dedupes one provider's repeat inside 300 ms.
Announcements bypass background-event filtering and foreground epochs: the
provider explicitly requested speech and may never own focus. They do not
move focus, review, or reading state. Screen-lock and secure-input gates
still suppress them. Assertive announcements interrupt; polite ones append.
still suppress them. Both polite and assertive announcements queue under Cthulhu's input-driven
interruption policy. Politeness metadata is preserved by the adapter.
Payload text is bounded by the adapter's existing snapshot sanitization.
## Automated checks
@@ -53,8 +54,9 @@ Report automated, audible-speech, and physical-Braille results separately.
2. Switch between named, unnamed, and the trailing empty workspace with
Niri's own binds. Each switch speaks the workspace label once, before
the newly focused window, and focus presentation still follows.
3. Burst-switch several times quickly. Only settled announcements speak;
no duplicates from the paired accessible-name change.
3. Burst-switch several times quickly. Announcements queue in arrival order;
fresh keyboard input may cancel earlier output. No duplicates should come
from the paired accessible-name change.
4. Cycle the AltTab switcher and open the exit dialog (`Super+Shift+E`)
and the hotkeys list (`Super+Shift+/`). Selected windows and dialog
text are announced as with Orca on the same session.
+28
View File
@@ -47,3 +47,31 @@ check pitch and volume with the quick speech keys, then save and reopen the
settings. Values such as 4.5 must remain 4.5 in announcements and TOML rather
than exposing floating-point tails. Check all voice types and custom key echo;
untouched inherited values must remain unset.
## Preferences focus with differing window bounds
On Slint/i3, GTK can report a frame at (0,0), sized 1916×994, while X11
reports its client at (2,18), sized 1912×974. Rejecting that frame by geometry
silences control announcements even though Tab still moves focus. The reader
registers the PID of its own preferences helper until the helper exits. That
helper can bypass the geometry comparison when X11 identifies the same
foreground process and GTK reports an active, showing, non-iconified window.
Other applications retain the normal foreground correlation checks.
The adapter dropdown regression also checks that preferences activation and
focused-control events are accepted:
```sh
go test ./internal/atspi ./internal/app -count=1
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_ADAPTER_ONLY=1 python3 tests/native_preferences.py
```
On the affected Slint desktop, use the rebuilt reader to open preferences.
Tab and Shift+Tab through controls, switch notebook pages with Ctrl+PageUp and
Ctrl+PageDown, open a dropdown, change its selection, and close it with Escape.
Confirm each control's name and value is announced. Apply a change, close and
reopen preferences, and confirm it persists. Switch to another application
while preferences remains open: speech and Braille must follow that application,
then resume preferences announcements when returning. Repeat after closing and
reopening preferences and after changing window size. Private X11 tests do not
establish physical-key, audible-speech or Braille acceptance on Slint.
+43 -8
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
)
// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits
@@ -40,10 +41,10 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
adapter.events <- announcementEvent("Workspace 2 Example", 1)
select {
case request := <-speech.requests:
if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
if request.Text != "Workspace 2 Example" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
!request.Append || !request.BypassFocusGate {
t.Fatalf("announcement request = %+v", request)
}
@@ -52,14 +53,14 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
}
}
func TestAnnouncementAssertiveInterrupts(t *testing.T) {
func TestAnnouncementAssertiveQueues(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Config parse error", 2)
select {
case request := <-speech.requests:
if request.Text != "Config parse error" || request.Append {
if request.Text != "Config parse error" || !request.Append {
t.Fatalf("assertive request = %+v", request)
}
case <-time.After(time.Second):
@@ -71,10 +72,10 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
adapter.events <- announcementEvent("Workspace 2 Example", 1)
// Niri can append a space to force re-emission of identical text.
adapter.events <- announcementEvent("Workspace 2 Mistworld ", 1)
adapter.events <- announcementEvent("Workspace 1 Generic Shit", 1)
adapter.events <- announcementEvent("Workspace 2 Example ", 1)
adapter.events <- announcementEvent("Workspace 1 Example", 1)
var got []string
for range 2 {
@@ -90,7 +91,7 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got)
case <-time.After(30 * time.Millisecond):
}
if got[0] != "Workspace 2 Mistworld" || got[1] != "Workspace 1 Generic Shit" {
if got[0] != "Workspace 2 Example" || got[1] != "Workspace 1 Example" {
t.Fatalf("announcements = %v", got)
}
}
@@ -106,3 +107,37 @@ func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) {
case <-time.After(30 * time.Millisecond):
}
}
func TestAnnouncementBrailleAndLockGate(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
speech := &remoteSpeechQueue{}
display := &flashRecorder{}
gate := newFocusGate(nil)
handler := &accessibilityEventHandler{
state: &eventLoopState{}, focusGate: gate,
speech: gatedSpeech{gate: gate, speech: outputQueue{speech: speech, braille: display, live: live}},
}
event := announcementEvent("Workspace 2 Example", 1)
handler.presentAnnouncement(event)
if len(display.messages) != 1 || display.messages[0] != event.Payload {
t.Fatalf("Braille flashes = %v", display.messages)
}
gate.secure.Store(true)
handler.handle(announcementEvent("Locked workspace", 1))
if len(display.messages) != 1 {
t.Fatal("locked announcement flashed")
}
}
func TestIdenticalAnnouncementFromAnotherProviderIsPreserved(t *testing.T) {
speech := &remoteSpeechQueue{}
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
event := announcementEvent("Example message", 1)
handler.presentAnnouncement(event)
event.Source.BusName = ":1.21"
handler.presentAnnouncement(event)
if len(speech.requests) != 2 {
t.Fatalf("requests = %d, want 2", len(speech.requests))
}
}
+1
View File
@@ -313,6 +313,7 @@ func Run(ctx context.Context, options Options) error {
preferences.speechWorker = speechMessages
preferences.braille = brailleOutput
preferences.documentReview = objectReview
preferences.preferencesProcess, _ = adapter.(interface{ SetPreferencesProcess(int) })
objectReview.SetDocumentBrailleEnabled(settings.EnableBraille)
if updater, ok := sounds.(soundSettingsUpdater); ok {
preferences.sound = updater
+3 -6
View File
@@ -12,12 +12,9 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// AtspiLive assertive value carried in an announcement event's Detail1.
const announcementAssertive = 2
// presentAnnouncement speaks an object:announcement payload, mirroring Orca:
// the provider's text is presented verbatim, assertive announcements
// interrupt, and polite ones append. Announcements commonly come from
// the provider's text is presented verbatim. All provider announcements queue
// under the reader's input-driven interruption policy. Announcements commonly come from
// providers that never own focus (a compositor announcing a workspace
// switch, for example), so no focus or foreground ownership is required and
// none is taken. The caller's lock gate has already run; the speech output
@@ -40,7 +37,7 @@ func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event)
Text: text,
Role: "system",
Cause: atspi.AnnouncementEvent,
Append: event.Detail1 != announcementAssertive,
Append: true,
BypassFocusGate: true,
})
}
+2 -2
View File
@@ -127,7 +127,7 @@ func (speaker gatedSpeech) InterruptInternalUI() {
}
func (speaker gatedSpeech) InterruptForUser() {
if speaker.gate.Locked() {
if speaker.gate.Locked() || speaker.gate.Suspended() {
return
}
speaker.gate.dismiss_confirmation()
@@ -139,7 +139,7 @@ func (speaker gatedSpeech) InterruptForUser() {
}
func (speaker gatedSpeech) InterruptForInput(event input.Event) bool {
if speaker.gate.InternalUIActive() {
if speaker.gate.InternalUIActive() || speaker.gate.Suspended() {
return false
}
if speaker.gate.Locked() {
+1 -1
View File
@@ -44,7 +44,7 @@ func processInputEvents(
if !ok {
return
}
if guarded, ok := speech.(gatedSpeech); ok && guarded.gate.Locked() {
if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) {
echo.Reset()
continue
}
+1
View File
@@ -65,6 +65,7 @@ type preferencesWorker struct {
refreshHistory func()
sound soundSettingsUpdater
geometry soundGeometryCaptureConfigurer
preferencesProcess interface{ SetPreferencesProcess(int) }
textAttributes textAttributeCaptureConfigurer
bindings interface {
SetKeyBindings(context.Context, []input.BindingOverride) error
+4
View File
@@ -69,6 +69,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) {
worker.live.preferencesPID.Store(int64(command.Process.Pid))
defer worker.live.preferencesPID.Store(0)
}
if worker.preferencesProcess != nil {
worker.preferencesProcess.SetPreferencesProcess(command.Process.Pid)
defer worker.preferencesProcess.SetPreferencesProcess(0)
}
defer func() {
stop()
in.Close()
+1 -1
View File
@@ -34,7 +34,7 @@ func (worker *preferencesWorker) manage_tentacle(ctx context.Context, request pr
return response
}
timeout := 6 * time.Second
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") {
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") || request.Tentacle.Action == "upgrade" {
timeout = 50 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
+1 -1
View File
@@ -118,7 +118,7 @@ func (worker commandRuntime) run(ctx context.Context) error {
}
}
case state := <-commands.LockStates():
if focusGate.Locked() {
if focusGate.Locked() || focusGate.Suspended() {
continue
}
if speechMessages != nil {
+5
View File
@@ -131,6 +131,11 @@ func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Servic
Stop: bridgeServer.Stop,
})
wineManager := wine.New(deps.logger)
if deps.live != nil {
settings := deps.live.Load()
wineManager.ControllerOnly = settings.Wine.ControllerOnly
wineManager.ExcludedPrograms = append([]string(nil), settings.Wine.ExcludedPrograms...)
}
services = append(services, lifecycle.Service{
Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop,
})
+6 -1
View File
@@ -43,7 +43,12 @@ func install_fixture(t *testing.T) (tentacle.Repository, []byte) {
marker := filepath.Join(t.TempDir(), "called")
manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker)
var data bytes.Buffer
compressed := gzip.NewWriter(&data)
// These fixtures test package installation, not compression ratios. This
// also keeps repeated update fixtures cheap under the race detector.
compressed, err := gzip.NewWriterLevel(&data, gzip.BestSpeed)
if err != nil {
t.Fatal(err)
}
archive := tar.NewWriter(compressed)
for _, item := range []struct {
name string
+111 -16
View File
@@ -30,6 +30,13 @@ type tentacleStartupCall struct {
call tentacleManagementCall
saveError error
stop func() bool
previous *tentacle.Entry
}
// A retired package stays on disk until the session using it has joined.
type tentacleRetiredPackage struct {
entry tentacle.Entry
session *tentacle.Session
}
func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
@@ -43,6 +50,7 @@ func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
defer runtime.mutex.Unlock()
result := runtime.snapshot
result.Repositories = tentacle.CloneRepositories(result.Repositories)
result.Updates = slices.Clone(result.Updates)
result.Entries = append([]tentacle.EntryStatus(nil), result.Entries...)
var bindings []input.BindingOverride
if runtime.input != nil && !runtime.disabled {
@@ -124,7 +132,7 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
if disabled {
return tentacle.ErrDisabled
}
if request.Action == "install" || request.Action == "install-enable" {
if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" {
if request.Entry != nil {
return errors.New("unexpected tentacle entry in installation request")
}
@@ -138,9 +146,37 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
}
repository := snapshot.Repositories[index]
id := tentacle.PackageID(repository, request.ID)
if slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) {
installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id })
if request.Action == "upgrade" && !installed {
return errors.New("tentacle is not installed")
}
if request.Action != "upgrade" && installed {
return errors.New("tentacle is already installed")
}
if request.Action == "upgrade" {
for _, status := range snapshot.Entries {
if status.Entry.ID != id {
continue
}
for _, manifest := range repository.Tentacles {
if manifest.ID != request.ID {
continue
}
if manifest.Version == status.Entry.Version {
return errors.New("tentacle is already at the repository version")
}
newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version)
if err != nil {
return fmt.Errorf("cannot compare tentacle versions: %w", err)
}
if !newer {
return errors.New("repository tentacle version is not newer than installed version")
}
break
}
break
}
}
runtime.mutex.Lock()
if runtime.repositoryBusy {
runtime.mutex.Unlock()
@@ -162,9 +198,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
return err
}
defer prepared.Discard()
prepared.Entry.Enabled = request.Action == "install-enable"
operation := "add"
if request.Action == "upgrade" {
operation = "update"
// The lifecycle owner preserves enablement at commit time.
} else {
prepared.Entry.Enabled = request.Action == "install-enable"
}
call.packageFiles, call.repository = prepared, &repository
call.request = tentacle.ManagementRequest{Action: "add", ID: prepared.Entry.ID, Entry: &prepared.Entry}
call.request = tentacle.ManagementRequest{Action: operation, ID: prepared.Entry.ID, Entry: &prepared.Entry}
}
if request.Action == "repository-add" || request.Action == "repository-refresh" {
if request.Entry != nil || request.ID != "" {
@@ -240,6 +282,7 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme
next = append(next, *request.Entry)
case "update":
next[index] = *request.Entry
next[index].Enabled = entries[index].Enabled
case "remove":
next = slices.Delete(next, index, index+1)
case "enable":
@@ -268,7 +311,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
sessions := make(map[string]*tentacle.Session)
problems := make(map[string]string)
startups := make(map[string]tentacleStartupCall)
removedPackages := make(map[string]tentacle.Entry)
removedPackages := make(map[string]tentacleRetiredPackage)
for _, entry := range entries {
pending[entry.ID] = entry.Enabled
}
@@ -315,6 +358,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
result.Entries = append(result.Entries, status)
}
result.Updates = tentacle.AvailableUpdates(result)
runtime.mutex.Lock()
runtime.snapshot = result
runtime.mutex.Unlock()
@@ -338,7 +382,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
if manager != nil {
managerError = manager.Shutdown(cleanup)
}
for _, entry := range removedPackages {
for _, retired := range removedPackages {
entry := retired.entry
if managerError == nil {
if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil {
runtime.logger.Warn("remove tentacle package", "error", err)
@@ -382,6 +427,11 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
delete(startups, id)
startup.stop()
if err == nil {
if startup.previous != nil {
previous := *startup.previous
// Starting the replacement already waited for the old session.
removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous}
}
err = startup.saveError
}
setSnapshot()
@@ -389,7 +439,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
failStartup := func(id, message string) {
problems[id] = message
if _, exists := startups[id]; !exists {
startup, exists := startups[id]
if !exists {
return
}
revoke(id)
@@ -400,15 +451,31 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
next := tentacle.CloneEntries(entries)
for index := range next {
if next[index].ID == id {
next[index].Enabled = false
if startup.previous != nil {
next[index] = *startup.previous
} else {
next[index].Enabled = false
}
}
}
err := runtime.registry.Save(next)
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
if startup.previous != nil {
failed := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id })
if failed >= 0 {
entry := entries[failed]
removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]}
}
runtime.mutex.Lock()
disabled := runtime.disabled
runtime.mutex.Unlock()
pending[id] = startup.previous.Enabled && !disabled && ctx.Err() == nil
message += " The previous package was restored."
}
entries = next
}
if err != nil {
message += " The disabled state could not be saved durably: " + err.Error()
message += " The recovery state could not be saved durably: " + err.Error()
}
replyStartup(id, errors.New(message))
}
@@ -467,15 +534,26 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
}
if current == nil {
for id, entry := range removedPackages {
if sessions[id] != nil {
for directory, retired := range removedPackages {
entry := retired.entry
if retired.session != nil {
select {
case <-retired.session.Done():
default:
continue
}
}
if slices.ContainsFunc(entries, func(active tentacle.Entry) bool {
return active.PackageDirectory == directory
}) {
continue
}
if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil {
runtime.logger.Warn("remove tentacle package", "error", err)
runtime.report("Tentacle was removed, but its package files could not be deleted.")
continue
}
delete(removedPackages, id)
delete(removedPackages, directory)
}
for _, entry := range entries {
if !pending[entry.ID] || sessions[entry.ID] != nil {
@@ -572,6 +650,9 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
call.result <- tentacle.ErrBusy
continue
}
if startup, exists := startups[call.request.ID]; exists && startup.previous != nil {
failStartup(call.request.ID, "Tentacle update was cancelled by another operation.")
}
next, err := change_tentacle_entries(entries, call.request)
if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) {
// Saved enablement is intent, not proof of a live session. An
@@ -591,15 +672,28 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
id := call.request.ID
var previous *tentacle.Entry
if call.packageFiles != nil && call.request.Action == "update" {
index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id })
if index >= 0 && entries[index].Enabled {
saved := tentacle.CloneEntries(entries[index : index+1])[0]
previous = &saved
}
}
replyStartup(id, errors.New("Tentacle startup was cancelled by another operation."))
if call.packageFiles != nil {
call.packageFiles.Retain()
}
if call.request.Action == "remove" {
if call.request.Action == "remove" || call.request.Action == "update" {
for _, entry := range entries {
if entry.ID == id && entry.PackageDirectory != "" {
removedPackages[id] = entry
if entry.ID != id || entry.PackageDirectory == "" {
continue
}
if call.request.Action == "update" && (previous != nil ||
entry.PackageDirectory == call.request.Entry.PackageDirectory) {
continue
}
removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]}
}
}
revoke(id)
@@ -619,7 +713,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
runtime.publishCommands(stillActive)
setSnapshot()
if pending[id] {
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify)}
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous}
continue
}
}
@@ -665,6 +759,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
continue
}
active = candidate
delete(problems, session.TentacleID())
runtime.publishCommands(active)
replyStartup(session.TentacleID(), nil)
runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity())
+22
View File
@@ -404,3 +404,25 @@ func TestManagedTentacleCancellationDuringBindingsRollsBack(t *testing.T) {
t.Fatalf("incorrect persisted rollback: %+v %v", disk, err)
}
}
// Updating an entry must preserve the user's enabled preference.
func TestUpdatePreservesEnabledState(t *testing.T) {
for _, enabled := range []bool{false, true} {
current := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: enabled, Version: "0.1.3"}
replacement := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: !enabled, Version: "0.1.4"}
next, err := change_tentacle_entries(
[]tentacle.Entry{current},
tentacle.ManagementRequest{
Action: "update",
ID: current.ID,
Entry: &replacement,
},
)
if err != nil {
t.Fatal(err)
}
if len(next) != 1 || next[0].Enabled != enabled || next[0].Version != "0.1.4" {
t.Fatalf("enabled=%v: unexpected result: %+v", enabled, next)
}
}
}
@@ -0,0 +1,90 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func TestUpgradePreparationFailurePreservesInstallation(t *testing.T) {
repository, data := install_fixture(t)
set_install_transport(t, func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
})
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
owner, _ := managed_tentacle_fixture(t, registryPath, false)
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
return repository, nil
}
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
manage := func(action string) error {
return owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: action,
URL: repository.URL,
ID: func() string {
if action == "install" || action == "upgrade" {
return "echo"
}
return ""
}(),
})
}
if err := manage("repository-add"); err != nil {
t.Fatal(err)
}
if err := manage("install"); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "disabled")
before := owner.Snapshot().Entries[0].Entry
repository.Revision = strings.Repeat("b", 40)
repository.Tentacles[0].Version = "0.2"
if err := manage("repository-refresh"); err != nil {
t.Fatal(err)
}
owner.preparePackage = func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) {
return nil, errors.New("simulated download failure")
}
if err := manage("upgrade"); err == nil {
t.Fatal("upgrade unexpectedly succeeded")
}
after := owner.Snapshot().Entries[0].Entry
if after.Version != before.Version ||
after.Enabled != before.Enabled ||
after.PackageDirectory != before.PackageDirectory {
t.Fatalf("failed upgrade changed installation: before=%+v after=%+v", before, after)
}
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatalf("failed upgrade removed installed package: %v", err)
}
}
@@ -0,0 +1,330 @@
package app
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func prepare_upgrade_fixture(t *testing.T, mode string) (*tentacleRuntime, *tentacleBindingRecorder, tentacle.Repository, tentacle.Entry) {
t.Helper()
repository, data := install_fixture(t)
set_install_transport(t, func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
ContentLength: int64(len(data)),
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
})
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
owner, bindings := managed_tentacle_fixture(t, registryPath, false)
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
return repository, nil
}
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-add", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "install-enable", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "active")
before := owner.Snapshot().Entries[0].Entry
// Rebuild the downloaded archive with a matching version 0.2 manifest.
var updated bytes.Buffer
gzipWriter, err := gzip.NewWriterLevel(&updated, gzip.BestSpeed)
if err != nil {
t.Fatal(err)
}
tarWriter := tar.NewWriter(gzipWriter)
tarReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
reader := tar.NewReader(tarReader)
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if header.Name == "tentacle.toml" {
content = bytes.Replace(content, []byte("version='0.1'"), []byte("version='0.2'"), 1)
if mode == "stall" {
content = bytes.Replace(content, []byte(before.Program[len(before.Program)-1]), []byte(filepath.Join(t.TempDir(), "stall")), 1)
}
}
if header.Name == "bin/echo" && mode == "fail" {
content, err = os.ReadFile("/bin/false")
if err != nil {
t.Fatal(err)
}
}
copyHeader := *header
copyHeader.Size = int64(len(content))
if err := tarWriter.WriteHeader(&copyHeader); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(content); err != nil {
t.Fatal(err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := tarReader.Close(); err != nil {
t.Fatal(err)
}
updatedData := updated.Bytes()
digest := sha256.Sum256(updatedData)
repository.Revision = strings.Repeat("b", 40)
repository.Tentacles[0].Version = "0.2"
repository.Tentacles[0].Releases = []tentacle.Release{{
Platform: "linux",
Architecture: runtime.GOARCH,
URL: "https://example.org/release-0.2.tar.gz",
SHA256: fmt.Sprintf("%x", digest),
}}
data = updatedData
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-refresh", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
return owner, bindings, repository, before
}
func TestUpgradeReplacesRunningPackageAndCleansOldFiles(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
id := before.ID
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "upgrade", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
wait_managed_state(t, owner, id, "active")
after := owner.Snapshot().Entries[0].Entry
if !after.Enabled {
t.Fatal("upgrade disabled previously enabled tentacle")
}
if after.PackageDirectory == before.PackageDirectory {
t.Fatal("upgrade reused old package directory")
}
waitTentacle(t, func() bool {
_, err := os.Stat(before.PackageDirectory)
return os.IsNotExist(err)
})
if _, err := os.Stat(after.PackageDirectory); err != nil {
t.Fatalf("replacement package missing: %v", err)
}
}
func TestUpgradeFailureAndCancellationRestorePreviousPackage(t *testing.T) {
for _, operation := range []string{"fail", "cancel", "disable", "remove", "shutdown"} {
t.Run(operation, func(t *testing.T) {
mode := "stall"
if operation == "fail" {
mode = "fail"
}
owner, bindings, repository, before := prepare_upgrade_fixture(t, mode)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
if operation != "fail" {
waitTentacle(t, func() bool {
snapshot := owner.Snapshot()
return len(snapshot.Entries) == 1 && snapshot.Entries[0].Entry.Version == "0.2" && snapshot.Entries[0].State == "starting"
})
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatalf("old package lost during startup: %v", err)
}
switch operation {
case "cancel":
cancel()
case "disable", "remove":
manage_tentacle(t, owner, operation, before.ID, nil)
case "shutdown":
if err := owner.Stop(ctx); err != nil {
t.Fatal(err)
}
}
}
select {
case err := <-result:
if err == nil {
t.Fatal("failed or cancelled update reported success")
}
case <-time.After(10 * time.Second):
t.Fatal("update did not finish")
}
if operation == "fail" || operation == "cancel" {
wait_managed_state(t, owner, before.ID, "active")
owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool { _, err := os.Stat(before.Program[len(before.Program)-1]); return err == nil })
}
if operation == "disable" {
wait_managed_state(t, owner, before.ID, "disabled")
}
if operation == "shutdown" {
restarted, _ := managed_tentacle_fixture(t, owner.registry.Path, false)
if err := restarted.Start(context.Background()); err != nil {
t.Fatal(err)
}
wait_managed_state(t, restarted, before.ID, "active")
}
disk, err := (&tentacle.Registry{Path: owner.registry.Path}).Load()
if err != nil {
t.Fatal(err)
}
if operation == "remove" {
if len(disk) != 0 {
t.Fatal(disk)
}
} else {
expected := before
if operation == "disable" {
expected.Enabled = false
}
if len(disk) != 1 || !reflect.DeepEqual(disk[0], expected) {
t.Fatalf("previous installation not restored: %+v", disk)
}
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatal(err)
}
}
waitTentacle(t, func() bool {
packages, err := filepath.Glob(filepath.Join(tentacle.PackageRoot(owner.registry.Path), "package-*"))
expected := 1
if operation == "remove" {
expected = 0
}
return err == nil && len(packages) == expected
})
})
}
}
func TestUpgradePreparationCancellationLeavesRunningPackage(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing := make(chan struct{})
owner.preparePackage = func(ctx context.Context, _ string, _ tentacle.Repository, _ string) (*tentacle.PreparedPackage, error) {
close(preparing)
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-time.After(5 * time.Second):
t.Fatal("preparation did not start")
}
cancel()
select {
case err := <-result:
if err == nil {
t.Fatal("cancelled preparation succeeded")
}
case <-time.After(5 * time.Second):
t.Fatal("preparation did not cancel")
}
status := owner.Snapshot().Entries[0]
if status.State != "active" || !reflect.DeepEqual(status.Entry, before) {
t.Fatalf("cancelled preparation changed running package: %+v", status)
}
}
func TestUpgradeUsesEnablementAtCommit(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing, release := make(chan struct{}), make(chan struct{})
owner.preparePackage = func(ctx context.Context, root string, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) {
close(preparing)
select {
case <-release:
case <-ctx.Done():
return nil, ctx.Err()
}
return tentacle.PreparePackage(ctx, root, repository, id)
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-ctx.Done():
t.Fatal("preparation did not start")
}
manage_tentacle(t, owner, "disable", before.ID, nil)
close(release)
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("upgrade did not finish")
}
wait_managed_state(t, owner, before.ID, "disabled")
status := owner.Snapshot().Entries[0]
if status.Entry.Version != "0.2" || status.Entry.Enabled {
t.Fatal(status)
}
waitTentacle(t, func() bool { _, err := os.Stat(before.PackageDirectory); return os.IsNotExist(err) })
}
+66
View File
@@ -260,3 +260,69 @@ func TestDelegatedInputSuppressesInterruptAndEcho(t *testing.T) {
})
}
}
// Match speechWorker's forwarding interfaces: a bare fake's Interrupt method
// alone cannot catch a bypass through InterruptForInput or InterruptForUser.
type forwardedInputSpeech struct{ *fakeSpeechQueue }
func (speech *forwardedInputSpeech) InterruptForInput(input.Event) bool {
speech.Interrupt()
return true
}
func (speech *forwardedInputSpeech) InterruptForUser() { speech.Interrupt() }
func TestDelegatedInputDoesNotForwardSpeechInterruption(t *testing.T) {
for _, suspended := range []bool{true, false} {
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
speech := &forwardedInputSpeech{&fakeSpeechQueue{interrupt: make(chan struct{}, 8)}}
gate := newFocusGate(nil)
gate.SetSuspended(suspended)
guarded := gatedSpeech{speech: speech, gate: gate}
if got := guarded.InterruptForInput(input.Event{Pressed: true}); got == suspended {
t.Fatalf("interruption admitted = %t, suspended = %t", got, suspended)
}
guarded.InterruptForUser()
want := 2
if suspended {
want = 0
}
if len(speech.interrupt) != want {
t.Fatalf("interruptions = %d, want %d", len(speech.interrupt), want)
}
})
}
}
func TestXTermHandoffSuppressesLockStateFeedback(t *testing.T) {
for _, suspended := range []bool{true, false} {
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
gate := newFocusGate(nil)
gate.SetSuspended(suspended)
commands := &fakeCommands{lockStates: make(chan input.LockState)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: gate, speechOutput: speech}
go func() { done <- worker.run(ctx); close(done) }()
defer func() { cancel(); <-done }()
for _, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} {
select {
case commands.lockStates <- input.LockState{Name: name, Locked: true}:
case <-time.After(time.Second):
t.Fatal("lock state was not consumed")
}
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
want := 3
if suspended {
want = 0
}
if len(speech.requests) != want {
t.Fatalf("lock-state requests = %d, want %d", len(speech.requests), want)
}
})
}
}
+1
View File
@@ -121,6 +121,7 @@ type Adapter struct {
foregroundReader *xorg.ForegroundReader // GLib thread only
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client
foreground atomic.Pointer[ForegroundState]
preferencesPID atomic.Int64 // Owned preferences helper; zero when closed.
echoFocus atomic.Pointer[echoFocus]
eventListener *C.AtspiEventListener // GLib owner; released with subscriptions.
foregroundChanges chan struct{}
@@ -129,6 +129,14 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
}
return false
}
// Tiling window managers can report GTK's frame and X11's client at
// different origins and sizes. Our own active preferences child is known
// by its launched PID; require X11 to confirm that process is foreground.
if preferences_foreground_matches(state, pid, adapter.preferencesPID.Load()) {
adapter.foregroundWindow = id
adapter.foregroundRecovery = 0
return true
}
component := C.atspi_accessible_get_component_iface(window)
if component == nil {
if check != nil {
+6 -2
View File
@@ -67,8 +67,12 @@ func TestNativeAnnouncementEventDelivered(t *testing.T) {
fmt.Fprintln(input, "announce polite Workspace 2 Fixture")
polite := wait("Workspace 2 Fixture")
t.Logf("polite announcement detail1=%d role=%s", polite.Detail1, polite.Source.Role)
if polite.Detail1 != 1 {
t.Fatalf("polite detail1 = %d, want 1", polite.Detail1)
}
fmt.Fprintln(input, "announce assertive Fixture error")
assertive := wait("Fixture error")
t.Logf("assertive announcement detail1=%d role=%s", assertive.Detail1, assertive.Source.Role)
if assertive.Detail1 != 2 {
t.Fatalf("assertive detail1 = %d, want 2", assertive.Detail1)
}
}
+11
View File
@@ -26,6 +26,17 @@ type ForegroundState struct {
Epoch uint64
}
// SetPreferencesProcess scopes geometry-independent foreground matching to the
// UI child launched by this reader. Clear it when that child exits.
func (adapter *Adapter) SetPreferencesProcess(pid int) {
adapter.preferencesPID.Store(int64(pid))
}
func preferences_foreground_matches(client xorg.Foreground, pid int, preferencesPID int64) bool {
return client.Known && client.Window != 0 && preferencesPID > 0 &&
int64(pid) == preferencesPID && client.PID == pid
}
func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) bool {
if client.PID > 0 && pid > 0 && client.PID != pid {
return false
+31
View File
@@ -44,6 +44,37 @@ func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) {
}
}
func TestPreferencesForegroundIdentity(t *testing.T) {
// Slint/i3 reports GTK's frame and the X11 client with different borders.
client := xorg.Foreground{Known: true, Window: 45, PID: 123,
X: 2, Y: 18, Width: 1912, Height: 974, OuterX: 2, OuterY: 18, OuterWidth: 1912, OuterHeight: 974}
if foregroundWindowMatches(client, 123, Rectangle{Width: 1916, Height: 994}) {
t.Fatal("fixture must reproduce the reported bounds mismatch")
}
for _, test := range []struct {
name string
client xorg.Foreground
pid int
owned int64
want bool
}{
{"owned foreground helper", client, 123, 123, true},
{"helper closed", client, 123, 0, false},
{"another reader's helper", client, 123, 124, false},
{"other provider", client, 124, 123, false},
{"helper in background", xorg.Foreground{Known: true, Window: 46, PID: 124}, 123, 123, false},
{"missing foreground PID", xorg.Foreground{Known: true, Window: 45}, 123, 123, false},
{"empty workspace", xorg.Foreground{Known: true, PID: 123}, 123, 123, false},
{"unknown foreground", xorg.Foreground{Window: 45, PID: 123}, 123, 123, false},
} {
t.Run(test.name, func(t *testing.T) {
if got := preferences_foreground_matches(test.client, test.pid, test.owned); got != test.want {
t.Fatalf("match = %v, want %v", got, test.want)
}
})
}
}
func TestForegroundMenuPopupCorrelation(t *testing.T) {
client := xorg.Foreground{Known: true, Window: 45, PID: 123}
main := NewObjectID(":1.40", "/main")
@@ -77,6 +77,8 @@ func TestNativePreferencesDropdownEscape(t *testing.T) {
if err := command.Start(); err != nil {
t.Fatal(err)
}
adapter.SetPreferencesProcess(command.Process.Pid)
defer adapter.SetPreferencesProcess(0)
helperDone := make(chan error, 1)
go func() { helperDone <- command.Wait() }()
helperJoined := false
@@ -118,6 +120,9 @@ func TestNativePreferencesDropdownEscape(t *testing.T) {
select {
case event := <-adapter.Events():
t.Logf("%s %s %q capture=%s", event.Type, event.Source.Role, event.Source.Name, event.SnapshotDuration)
if (event.Type == WindowActivatedEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.ForegroundMismatch {
t.Fatalf("owned preferences focus rejected: %+v", event)
}
if event.Type == FocusedStateEvent && event.Detail1 == 1 {
// Match the running reader's browser-mode classification.
// Without these fresh ancestry queries, stale cached focus
+8 -1
View File
@@ -58,7 +58,8 @@ func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Setti
func profileFromSettings(settings Settings) writableProfile {
settings = RoundSpeechValues(settings)
return writableProfile{
OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)},
Wine: writableWine{ControllerOnly: boolPointer(settings.Wine.ControllerOnly), ExcludedPrograms: &settings.Wine.ExcludedPrograms},
OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)},
ProgressBar: writableProgressBar{
OutputMode: stringPointer(settings.ProgressBar.OutputMode),
ReportBackground: boolPointer(settings.ProgressBar.ReportBackground),
@@ -169,6 +170,12 @@ func profileFromSettings(settings Settings) writableProfile {
}
func applyProfile(settings *Settings, profile writableProfile) {
if profile.Wine.ControllerOnly != nil {
settings.Wine.ControllerOnly = *profile.Wine.ControllerOnly
}
if profile.Wine.ExcludedPrograms != nil {
settings.Wine.ExcludedPrograms = append([]string(nil), (*profile.Wine.ExcludedPrograms)...)
}
apply_profile_ocr(&settings.OCR, profile.OCR)
// Keep the established patch order; absent fields leave prior values intact.
apply_profile_formats(settings, profile)
+6
View File
@@ -32,7 +32,13 @@ type writableDocument struct {
// Profiles are patches over built-in defaults. The writer emits a complete
// active profile so saved behavior remains stable if defaults later change.
type writableWine struct {
ControllerOnly *bool `toml:"controller-only,omitempty"`
ExcludedPrograms *[]string `toml:"excluded-hook-programs,omitempty"`
}
type writableProfile struct {
Wine writableWine `toml:"wine"`
OCR writableOCR `toml:"ocr"`
ProgressBar writableProgressBar `toml:"progress-bar"`
Presentation writablePresentation `toml:"presentation"`
+6
View File
@@ -112,7 +112,13 @@ func DefaultProgressBarSettings() ProgressBarSettings {
return ProgressBarSettings{OutputMode: "beep", BeepPercentageInterval: 1, SpeechPercentageInterval: 10}
}
type WineSettings struct {
ControllerOnly bool
ExcludedPrograms []string
}
type Settings struct {
Wine WineSettings
StructuralNavigationTriggersFocusMode bool
OCR OCRSettings
+1
View File
@@ -22,6 +22,7 @@ import (
)
func cloneSettings(settings Settings) Settings {
settings.Wine.ExcludedPrograms = append([]string(nil), settings.Wine.ExcludedPrograms...)
settings.SpeechInterruption.Keys = append([]string(nil), settings.SpeechInterruption.Keys...)
settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings)
settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...)
+15
View File
@@ -24,6 +24,7 @@ import (
"path/filepath"
"strings"
"unicode"
"unicode/utf16"
"git.stormux.org/storm/cthulhu/internal/datetime"
"git.stormux.org/storm/cthulhu/internal/input"
@@ -33,6 +34,20 @@ import (
func Validate(settings Settings) error { return validate(settings) }
func validate(settings Settings) error {
if len(settings.Wine.ExcludedPrograms) > 256 {
return errors.New("too many Wine hook exclusions")
}
exclusionUnits := 0
for _, name := range settings.Wine.ExcludedPrograms {
exclusionUnits += len(utf16.Encode([]rune(name))) + 1
if name == "" || len(name) > 255 || strings.TrimSpace(name) != name || strings.ContainsAny(name, "\x00\r\n/\\") {
return errors.New("Wine hook exclusions must be executable basenames")
}
}
if exclusionUnits >= 32766 {
return errors.New("Wine hook exclusion list is too long")
}
if err := settings.SpeechInterruption.Validate(); err != nil {
return err
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"path/filepath"
"reflect"
"testing"
)
func TestWineSettingsRoundTripAndValidation(t *testing.T) {
settings := DefaultSettings()
settings.Wine = WineSettings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe", "Another Game.exe"}}
path := filepath.Join(t.TempDir(), "settings.toml")
store, err := NewStore(path)
if err != nil {
t.Fatal(err)
}
if err := store.Save(settings); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Wine, settings.Wine) {
t.Fatalf("Wine settings = %+v", loaded.Wine)
}
cloned := cloneSettings(settings)
cloned.Wine.ExcludedPrograms[0] = "Changed.exe"
if settings.Wine.ExcludedPrograms[0] != "Game.exe" {
t.Fatal("clone aliases exclusions")
}
for _, name := range []string{"", "../game.exe", `C:\game.exe`, "Game.exe\rOther.exe", "game.exe\n"} {
settings.Wine.ExcludedPrograms = []string{name}
if err := Validate(settings); err == nil {
t.Fatalf("accepted exclusion %q", name)
}
}
}
+9 -1
View File
@@ -381,6 +381,13 @@ static void grab_command_keys(CommandDevice *device) {
}
}
int set_command_keys(CommandDevice *device, int enabled) {
// Another reader may have changed Caps Lock while command input was
// delegated. Adopt that state before treating Caps as our modifier again.
if (enabled && !device->commands && device->capsCode) {
XkbStateRec state;
if (XkbGetState(device->display, XkbUseCoreKbd, &state) != Success) return -7;
device->capsLocked = (state.locked_mods & LockMask) != 0;
}
pthread_mutex_lock(&error_mutex);
XSync(device->display,False);
error_display=device->display; grab_error=0;
@@ -491,6 +498,7 @@ CommandDevice *open_commands(int useInsert, int useKeypad, int useCaps, int *err
*error=set_command_keys(device,1);
if (*error) { XCloseDisplay(device->display); free(device); return NULL; }
if (device->capsCode) {
device->capsLocked=0;
XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,0);
XSync(device->display,False);
}
@@ -502,7 +510,7 @@ int command_lock_state(CommandDevice *device) {
int locks=0;
for (int i=0;i<3;i++) if (indicators & device->lockIndicators[i]) locks |= 1<<i;
// Ignore the transient XKB lock action on a grabbed reader modifier.
if (device->capsCode) locks=(locks & ~1) | device->capsLocked;
if (device->commands && device->capsCode) locks=(locks & ~1) | device->capsLocked;
return locks;
}
void toggle_command_caps_lock(CommandDevice *device) {
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"testing"
"time"
@@ -23,7 +24,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) {
if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil {
t.Fatalf("compile native probe: %v: %s", err, out)
}
for _, mode := range []string{"queued", "replay", "passive"} {
for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} {
t.Run(mode, func(t *testing.T) {
if out, err := exec.CommandContext(ctx, probe, mode).CombinedOutput(); err != nil {
t.Fatalf("native suspension: %v: %s", err, out)
@@ -39,12 +40,13 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
t.Skip("requires isolated X11 display")
}
for _, scenario := range []struct {
name string
learn, held bool
name string
learn, held, caps bool
}{
{name: "delayed_focus"},
{name: "held_command", held: true},
{name: "learn_mode", learn: true},
{name: "caps_modifier", caps: true},
} {
t.Run(scenario.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
@@ -86,7 +88,13 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
}
return state.Keys
}
listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
modifiers, layout := []string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout
reviewChord, reviewCommand := "KP_Begin", input.ReviewCurrentWord
if scenario.caps {
modifiers, layout = []string{"Caps_Lock"}, input.LaptopKeyboardLayout
reviewChord, reviewCommand = "Caps_Lock+i", input.ReviewCurrentLine
}
listener := NewCommands(modifiers, layout)
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
@@ -140,9 +148,9 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
defer exec.Command("xdotool", "keyup", "KP_Begin").Run()
key("keydown", "KP_Begin")
} else {
key("key", "KP_Begin")
key("key", reviewChord)
}
command(input.ReviewCurrentWord)
command(reviewCommand)
if !scenario.held {
wait(func() bool { return !listener.CommandGrabActive() })
}
@@ -168,10 +176,14 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
time.Sleep(2 * xtermFocusCheckInterval)
}
action(map[string]string{"action": "clear"})
key("key", "KP_Begin")
key("key", reviewChord)
keys := action(map[string]string{"action": "state"})
if len(keys) != 2 || keys[0].Symbol != 0xff9d || !keys[0].Pressed || keys[1].Symbol != 0xff9d || keys[1].Pressed {
t.Fatalf("xterm keypad press/release = %+v; unknown=%t, grabs=%t, active=%t", keys, unknown, listener.CommandGrabsEnabled(), listener.CommandGrabActive())
expected := []deliveredKey{{Pressed: true, Symbol: 0xff9d}, {Pressed: false, Symbol: 0xff9d}}
if scenario.caps {
expected = []deliveredKey{{Pressed: true, Symbol: 0xffe5}, {Pressed: true, Symbol: 0x69}, {Pressed: false, Symbol: 0xffe5}, {Pressed: false, Symbol: 0x69}}
}
if !slices.Equal(keys, expected) {
t.Fatalf("xterm key press/release = %+v, want %+v; unknown=%t, grabs=%t, active=%t", keys, expected, unknown, listener.CommandGrabsEnabled(), listener.CommandGrabActive())
}
select {
case got := <-listener.Commands():
@@ -183,8 +195,8 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
action(map[string]string{"action": "activate"})
focus(false)
wait(listener.CommandGrabsEnabled)
key("key", "KP_Begin")
command(input.ReviewCurrentWord)
key("key", reviewChord)
command(reviewCommand)
wait(func() bool { return !listener.CommandGrabActive() })
})
}
+5 -2
View File
@@ -76,7 +76,7 @@ void prefs_init_tentacles(void) {
const struct { const char *id; int action; } buttons[] = {
{"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14},
{"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18},
{"tentacleInstall", 19}, {"tentacleSettings", 20},
{"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20},
};
for (unsigned i = 0; i < G_N_ELEMENTS(buttons); i++)
g_signal_connect(prefs_widget(buttons[i].id), "clicked", G_CALLBACK(tentacle_clicked), GINT_TO_POINTER(buttons[i].action));
@@ -124,6 +124,7 @@ void prefs_tentacle_controls(int available, int installed, int enabled, int repo
gtk_widget_set_sensitive(prefs_widget("tentacleSettings"), available && installed);
gtk_widget_set_sensitive(prefs_widget("tentacleRemove"), available && installed);
gtk_widget_set_sensitive(prefs_widget("tentacleInstall"), available && !installed && prefs_tentacle_selected() >= 0);
gtk_widget_set_sensitive(prefs_widget("tentacleUpdate"), available && installed && prefs_tentacle_selected() >= 0);
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRefresh"), available && repository);
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRemove"), available && repository);
}
@@ -131,7 +132,9 @@ int prefs_tentacle_confirm(const char *message, const char *label) {
GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(prefs_widget("window")),
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_QUESTION,
GTK_BUTTONS_NONE, "%s", message);
gtk_window_set_title(GTK_WINDOW(dialog), "Install tentacle");
gchar *title = g_strdup_printf("%s tentacle", label);
gtk_window_set_title(GTK_WINDOW(dialog), title);
g_free(title);
gtk_dialog_add_button(GTK_DIALOG(dialog), "Cancel", GTK_RESPONSE_CANCEL);
gtk_dialog_add_button(GTK_DIALOG(dialog), label, GTK_RESPONSE_ACCEPT);
gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL);
+1
View File
@@ -307,6 +307,7 @@
</object></child>
<child><object class="GtkButtonBox"><property name="layout-style">start</property><property name="spacing">6</property>
<child><object class="GtkButton" id="tentacleInstall"><property name="label">Install</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleUpdate"><property name="label">Update</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleSettings"><property name="label">Settings</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleRetry"><property name="label">Retry</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleRemove"><property name="label">Remove tentacle</property><property name="can-focus">True</property></object></child>
+56
View File
@@ -212,6 +212,62 @@ func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error
return nil, errors.New("Select a tentacle first.")
}
page.selectedKey = selected.key
if action == 21 {
if !selected.installed {
return nil, errors.New("Install this tentacle first.")
}
entry := selected.status.Entry
for _, update := range page.snapshot.Updates {
if update.ID != entry.ID {
continue
}
permissions := ""
foundManifest := false
for _, repository := range page.snapshot.Repositories {
if repository.URL != update.RepositoryURL || repository.ID != update.RepositoryID {
continue
}
for _, manifest := range repository.Tentacles {
if manifest.ID != update.LocalID {
continue
}
foundManifest = true
var previous []string
if entry.Presentation {
previous = append(previous, "presentation")
}
if entry.Interaction {
previous = append(previous, "interaction")
}
if entry.ReaderSettings {
previous = append(previous, "reader-settings")
}
if len(previous) == 0 {
previous = append(previous, "none")
}
declared := manifest.Capabilities
if len(declared) == 0 {
declared = []string{"none"}
}
permissions = "Current capabilities: " + strings.Join(previous, ", ") +
". Repository declares: " + strings.Join(declared, ", ") + "."
}
}
if !foundManifest {
return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.")
}
message := fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions)
text, button := C.CString(message), C.CString("Update")
accepted := C.prefs_tentacle_confirm(text, button)
C.free(unsafe.Pointer(text))
C.free(unsafe.Pointer(button))
if accepted == 0 {
return nil, nil
}
return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil
}
return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.")
}
if !selected.installed {
if action != 11 && action != 19 {
return nil, errors.New("Install this tentacle first.")
+4 -1
View File
@@ -145,7 +145,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
session.refreshSpeechVoices()
session.refreshKeyBindingLayout()
action := C.prefs_action()
if action >= 9 && action <= 20 && !busy {
if action >= 9 && action <= 21 && !busy {
request, err := tentacles.action(int(action))
if err != nil {
status(err.Error(), true)
@@ -164,6 +164,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
if request.Action == "install" || request.Action == "install-enable" {
status("Downloading and checking the tentacle package. Close preferences to cancel before it is saved.", true)
}
if request.Action == "upgrade" {
status("Downloading and checking the tentacle update. Close preferences to cancel.", true)
}
}
continue
}
+1
View File
@@ -24,4 +24,5 @@ type Snapshot struct {
Message string
Suppressed bool
Repositories []Repository
Updates []AvailableUpdate
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"fmt"
"strings"
)
// AvailableUpdate describes a newer repository release for an installed tentacle.
type AvailableUpdate struct {
ID string
Name string
InstalledVersion string
AvailableVersion string
RepositoryURL string
RepositoryID string
LocalID string
}
// packageVersion accepts dotted numeric releases (including existing short
// versions such as 0.1), with optional SemVer prerelease and build identifiers.
type packageVersion struct {
numbers []string
prerelease []string
}
func numeric_identifier(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func version_identifiers(value string, prerelease bool) ([]string, error) {
identifiers := strings.Split(value, ".")
for _, identifier := range identifiers {
if identifier == "" {
return nil, fmt.Errorf("empty version identifier")
}
for _, ch := range identifier {
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '-') {
return nil, fmt.Errorf("invalid version identifier %q", identifier)
}
}
if prerelease && numeric_identifier(identifier) && len(identifier) > 1 && identifier[0] == '0' {
return nil, fmt.Errorf("prerelease number has a leading zero")
}
}
return identifiers, nil
}
func parse_package_version(value string) (packageVersion, error) {
var result packageVersion
core := strings.TrimPrefix(strings.TrimSpace(value), "v")
if before, build, found := strings.Cut(core, "+"); found {
if _, err := version_identifiers(build, false); err != nil {
return result, err
}
core = before
}
if before, prerelease, found := strings.Cut(core, "-"); found {
var err error
result.prerelease, err = version_identifiers(prerelease, true)
if err != nil {
return result, err
}
core = before
}
result.numbers = strings.Split(core, ".")
for _, number := range result.numbers {
if !numeric_identifier(number) {
return result, fmt.Errorf("invalid numeric version %q", value)
}
}
return result, nil
}
// Compare decimal strings without an integer-size limit.
func compare_version_number(first, second string) int {
first, second = strings.TrimLeft(first, "0"), strings.TrimLeft(second, "0")
if len(first) < len(second) {
return -1
}
if len(first) > len(second) {
return 1
}
return strings.Compare(first, second)
}
// IsNewerVersion compares release and prerelease precedence. Build metadata
// does not change precedence; missing numeric components are treated as zero.
func IsNewerVersion(available, installed string) (bool, error) {
first, err := parse_package_version(available)
if err != nil {
return false, err
}
second, err := parse_package_version(installed)
if err != nil {
return false, err
}
for index := 0; index < len(first.numbers) || index < len(second.numbers); index++ {
firstNumber, secondNumber := "0", "0"
if index < len(first.numbers) {
firstNumber = first.numbers[index]
}
if index < len(second.numbers) {
secondNumber = second.numbers[index]
}
if compared := compare_version_number(firstNumber, secondNumber); compared != 0 {
return compared > 0, nil
}
}
if len(first.prerelease) == 0 || len(second.prerelease) == 0 {
return len(first.prerelease) == 0 && len(second.prerelease) != 0, nil
}
for index := 0; index < len(first.prerelease) && index < len(second.prerelease); index++ {
firstID, secondID := first.prerelease[index], second.prerelease[index]
firstNumeric, secondNumeric := numeric_identifier(firstID), numeric_identifier(secondID)
compared := strings.Compare(firstID, secondID)
if firstNumeric && secondNumeric {
compared = compare_version_number(firstID, secondID)
}
if firstNumeric != secondNumeric {
return !firstNumeric, nil
}
if compared != 0 {
return compared > 0, nil
}
}
return len(first.prerelease) > len(second.prerelease), nil
}
// AvailableUpdates finds newer versions of installed repository tentacles.
func AvailableUpdates(snapshot Snapshot) []AvailableUpdate {
var updates []AvailableUpdate
for _, status := range snapshot.Entries {
entry := status.Entry
if entry.Repository == "" || entry.Version == "" {
continue
}
for _, repository := range snapshot.Repositories {
if repository.URL != entry.Repository || repository.ID != entry.RepositoryID {
continue
}
for _, manifest := range repository.Tentacles {
if manifest.ID != entry.LocalID {
continue
}
newer, err := IsNewerVersion(manifest.Version, entry.Version)
if err != nil || !newer {
continue
}
updates = append(updates, AvailableUpdate{
ID: entry.ID,
Name: manifest.Name,
InstalledVersion: entry.Version,
AvailableVersion: manifest.Version,
RepositoryURL: repository.URL,
RepositoryID: repository.ID,
LocalID: manifest.ID,
})
}
}
}
return updates
}
+61
View File
@@ -0,0 +1,61 @@
package tentacle
import "testing"
func TestVersionPrecedence(t *testing.T) {
ordered := []string{"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0", "1.1", "2.0"}
for firstIndex, first := range ordered {
for secondIndex, second := range ordered {
newer, err := IsNewerVersion(first, second)
if err != nil || newer != (firstIndex > secondIndex) {
t.Fatalf("%s > %s: %v, %v", first, second, newer, err)
}
}
}
for _, pair := range [][2]string{{"v0.1", "0.1.0"}, {"1.0.0+build.2", "1.0.0+build.1"}, {"2026.11.01", "2026.11.1"}} {
for _, versions := range [][2]string{pair, {pair[1], pair[0]}} {
if newer, err := IsNewerVersion(versions[0], versions[1]); err != nil || newer {
t.Fatalf("equal precedence: %v: %v %v", versions, newer, err)
}
}
}
if newer, err := IsNewerVersion("999999999999999999999999999", "2"); err != nil || !newer {
t.Fatalf("large numeric component: %v %v", newer, err)
}
for _, invalid := range []string{"", "nightly", "1..0", "1.-2", "1.0-", "1.0-01", "1.0+", "1.0+foo+bar", "1.0-a_1"} {
if _, err := IsNewerVersion(invalid, "1.0"); err == nil {
t.Errorf("accepted invalid version %q", invalid)
}
if _, err := IsNewerVersion("2.0", invalid); err == nil {
t.Errorf("accepted invalid installed version %q", invalid)
}
}
}
func TestAvailableUpdatesRequiresExactSource(t *testing.T) {
repository := Repository{URL: "https://example.org/tentacles", ID: "example.source", Tentacles: []Manifest{{ID: "echo", Name: "Echo", Version: "1.0"}}}
entry := Entry{Repository: repository.URL, RepositoryID: repository.ID, LocalID: "echo", Version: "1.0-rc.1"}
entry.ID = PackageID(repository, entry.LocalID)
snapshot := Snapshot{Entries: []EntryStatus{{Entry: entry}}, Repositories: []Repository{repository}}
updates := AvailableUpdates(snapshot)
if len(updates) != 1 || updates[0].ID != entry.ID || updates[0].AvailableVersion != "1.0" {
t.Fatalf("missing release update: %+v", updates)
}
for _, changed := range []string{"url", "repository-id", "local-id", "version"} {
candidate := snapshot
candidate.Repositories = CloneRepositories(snapshot.Repositories)
switch changed {
case "url":
candidate.Repositories[0].URL = "https://other.example.org/tentacles"
case "repository-id":
candidate.Repositories[0].ID = "example.replacement"
case "local-id":
candidate.Repositories[0].Tentacles[0].ID = "different"
case "version":
candidate.Repositories[0].Tentacles[0].Version = entry.Version
}
if updates := AvailableUpdates(candidate); len(updates) != 0 {
t.Errorf("%s offered unrelated or equal release: %+v", changed, updates)
}
}
}
+49 -28
View File
@@ -21,24 +21,28 @@ const managedKey = "CTHULHU_WINE_ACCESS_MANAGED"
type prefix struct {
path, loader string
environment map[string]string
programs map[string]bool
}
type process struct {
cmd *exec.Cmd
done chan struct{}
started time.Time
seen time.Time
failures int
next time.Time
stopping atomic.Bool
cmd *exec.Cmd
done chan struct{}
started time.Time
seen time.Time
failures int
next time.Time
stopping atomic.Bool
controllerOnly bool
}
type Manager struct {
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
logger *slog.Logger
helper string
procRoot string
cancel context.CancelFunc
done chan struct{}
ControllerOnly bool
ExcludedPrograms []string
}
func New(logger *slog.Logger) *Manager {
@@ -151,7 +155,11 @@ func (manager *Manager) poll(active map[string]*process) {
child.next = now.Add(backoff(child.failures))
child.cmd = nil
default:
continue
if manager.controller_only(source) && !child.controllerOnly {
stop(child)
} else {
continue
}
}
}
if now.Before(child.next) {
@@ -179,7 +187,13 @@ func backoff(failures int) time.Duration {
}
func (manager *Manager) launch(child *process, source prefix, now time.Time) error {
cmd := exec.Command(manager.helper)
helper, err := stage_helper(manager.helper, source.environment)
if err != nil {
return err
}
controllerOnly := child.controllerOnly || manager.controller_only(source)
arguments := manager.helper_arguments(controllerOnly)
cmd := exec.Command(helper, arguments...)
diagnostics := newHelperDiagnostics()
cmd.Stdin = nil
cmd.Stdout = nil
@@ -187,11 +201,17 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
// Bound stderr draining if a Wine descendant retains the inherited pipe.
cmd.WaitDelay = 200 * time.Millisecond
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
environment := os.Environ()
environment := make([]string, 0, len(os.Environ())+8)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if key != "WINEESYNC" && key != "WINEFSYNC" {
environment = append(environment, entry)
}
}
// Hooks need the application's display, but D-Bus requests go to this
// reader's session. Applications launched from another session bus must
// not redirect the helper away from the service supervising it.
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"} {
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "WINEESYNC", "WINEFSYNC"} {
if value := source.environment[key]; value != "" {
environment = append(environment, key+"="+value)
}
@@ -206,6 +226,7 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
}
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
child.stopping.Store(false)
child.controllerOnly = controllerOnly
logger := manager.logger.With("helper_pid", cmd.Process.Pid)
logger.Info("Wine accessibility helper started")
logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path,
@@ -317,17 +338,13 @@ func discover(root string, uid uint32) map[string]prefix {
if !filepath.IsAbs(path) {
continue
}
loader := environment["WINELOADER"]
if !executable(loader) {
loader = exe
if strings.HasSuffix(name, "-preloader") {
loader = strings.TrimSuffix(exe, "-preloader")
}
if !executable(loader) {
loader = ""
}
loader := wine_loader(exe, environment["WINELOADER"])
programs := map[string]bool{program_name(string(commandLine)): true}
if previous, ok := result[path]; ok {
previous.programs[program_name(string(commandLine))] = true
continue
}
result[path] = prefix{path: path, loader: loader, environment: environment}
result[path] = prefix{path: path, loader: loader, environment: environment, programs: programs}
}
return result
}
@@ -341,9 +358,13 @@ func applicationProcess(commandLine string) bool {
if program == "" {
return false
}
program = filepath.Base(strings.ReplaceAll(program, "\\", "/"))
normalized := strings.ReplaceAll(program, "\\", "/")
if strings.HasSuffix(normalized, "/windows/system32/steam.exe") || strings.HasSuffix(normalized, "/windows/syswow64/steam.exe") {
return false
}
program = filepath.Base(normalized)
switch program {
case "", "services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe":
case "", "xalia.exe", "conhost.exe", "tabtip.exe", "start.exe", "services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe":
return false
case "explorer.exe":
// Ordinary Explorer windows are applications; its desktop host is not.
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"bytes"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func program_name(commandLine string) string {
name := strings.SplitN(commandLine, "\x00", 2)[0]
return filepath.Base(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(name)), "\\", "/"))
}
func wine_loader(exe, configured string) string {
if executable(configured) {
// Protontricks' proxy requires its own runtime environment. Join the
// existing server with the actual Proton loader instead.
file, err := os.Open(configured)
if err == nil {
data := make([]byte, 8192)
count, _ := file.Read(data)
file.Close()
if !bytes.Contains(data[:count], []byte("PROTONTRICKS_STEAM_RUNTIME")) {
return configured
}
}
}
directory := filepath.Dir(exe)
for _, name := range []string{strings.TrimSuffix(filepath.Base(exe), "-preloader"), "wine"} {
candidate := filepath.Join(directory, name)
if executable(candidate) {
return candidate
}
}
for range 5 {
parent := filepath.Dir(directory)
if parent == directory || parent == string(filepath.Separator) {
break
}
directory = parent
for _, name := range []string{"wine", "wine64"} {
candidate := filepath.Join(directory, "bin", name)
if executable(candidate) {
return candidate
}
}
}
return ""
}
func (manager *Manager) controller_only(source prefix) bool {
if manager.ControllerOnly || os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "" && os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "0" {
return true
}
for _, name := range manager.ExcludedPrograms {
if source.programs[strings.ToLower(name)] {
return true
}
}
return false
}
func (manager *Manager) helper_arguments(controllerOnly bool) []string {
var arguments []string
names := append([]string(nil), manager.ExcludedPrograms...)
sort.Strings(names)
for _, name := range names {
// Validate again at the process boundary; never pass paths or separators
// into the native hook exclusion list.
if name != "" && !strings.ContainsAny(name, "\x00\r\n/\\") {
arguments = append(arguments, "--exclude-hook-program", strings.ToLower(name))
}
}
if controllerOnly {
arguments = append(arguments, "--no-dialog-reader")
}
return arguments
}
// A pressure-vessel wineserver cannot see the host's /usr. Publish complete,
// immutable helper sets in the home-mounted state directory. Never overwrite
// an older set: running processes may still have its sidecar mapped.
func stage_helper(helper string, environment map[string]string) (string, error) {
if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present {
return helper, nil
}
stateHome := os.Getenv("XDG_STATE_HOME")
if stateHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
stateHome = filepath.Join(home, ".local", "state")
}
if !filepath.IsAbs(stateHome) {
return "", fmt.Errorf("Wine helper state directory must be absolute")
}
names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
contents := make(map[string][]byte)
modes := make(map[string]os.FileMode)
digest := sha256.New()
for _, name := range names {
path := filepath.Join(filepath.Dir(helper), name)
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read helper set: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
contents[name], modes[name] = data, 0600
if info.Mode().Perm()&0111 != 0 {
modes[name] = 0700
}
fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), modes[name])
digest.Write(data)
}
root := filepath.Join(stateHome, "cthulhu", "wine-access", "helper")
if err := os.MkdirAll(root, 0700); err != nil {
return "", err
}
stage := filepath.Join(root, fmt.Sprintf("%x", digest.Sum(nil)))
if _, err := os.Stat(stage); os.IsNotExist(err) {
temporary, err := os.MkdirTemp(root, ".stage-")
if err != nil {
return "", err
}
defer os.RemoveAll(temporary)
for _, name := range names {
if err := os.WriteFile(filepath.Join(temporary, name), contents[name], modes[name]); err != nil {
return "", err
}
}
if err := os.Rename(temporary, stage); err != nil {
if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() {
return "", err
}
}
}
for _, name := range names {
path := filepath.Join(stage, name)
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !bytes.Equal(data, contents[name]) || info.Mode().Perm() != modes[name] {
return "", fmt.Errorf("incomplete or modified staged Wine helper")
}
}
return filepath.Join(stage, filepath.Base(helper)), nil
}
+200
View File
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package wine
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
func write_helper_file(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0700); err != nil {
t.Fatal(err)
}
}
func TestProtonLoaderBypassesProxy(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "Proton", "files", "bin", "wine")
preloader := filepath.Join(root, "Proton", "files", "lib", "wine", "i386-unix", "wine64-preloader")
proxy := filepath.Join(root, "proxy", "wine")
write_helper_file(t, loader, "loader")
write_helper_file(t, preloader, "preloader")
write_helper_file(t, proxy, "#!/bin/sh\n# PROTONTRICKS_STEAM_RUNTIME\n")
for _, configured := range []string{"", proxy, loader} {
if got := wine_loader(preloader, configured); got != loader {
t.Fatalf("loader = %q, want %q", got, loader)
}
}
}
func TestContainerHelperPublishesImmutableCompleteSets(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
helper := filepath.Join(root, "installed", "wine-access.exe")
names := []string{"wine-access.exe", "wine-access.exe.so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
for _, name := range names {
write_helper_file(t, filepath.Join(filepath.Dir(helper), name), name)
}
environment := map[string]string{"PRESSURE_VESSEL_RUNTIME": "runtime"}
original, err := stage_helper(helper, environment)
if err != nil {
t.Fatal(err)
}
if original == helper {
t.Fatal("helper was not staged")
}
for _, name := range names {
data, err := os.ReadFile(filepath.Join(filepath.Dir(original), name))
if err != nil || string(data) != name {
t.Fatalf("incomplete set: %s: %v", name, err)
}
}
write_helper_file(t, helper+".so", "new sidecar")
updated, err := stage_helper(helper, environment)
if err != nil || updated == original {
t.Fatalf("updated set = %q, %v", updated, err)
}
data, _ := os.ReadFile(original + ".so")
if string(data) != "wine-access.exe.so" {
t.Fatal("old sidecar overwritten")
}
write_helper_file(t, updated+".so", "tampered")
if _, err := stage_helper(helper, environment); err == nil {
t.Fatal("accepted modified staging")
}
if err := os.Remove(helper + ".so"); err != nil {
t.Fatal(err)
}
if _, err := stage_helper(helper, environment); err == nil {
t.Fatal("accepted incomplete source set")
}
if got, err := stage_helper(helper, nil); err != nil || got != helper {
t.Fatalf("ordinary Wine = %q, %v", got, err)
}
}
func TestControllerOnlyExclusionsReachLauncherBeforeGameStarts(t *testing.T) {
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
manager := &Manager{ExcludedPrograms: []string{"Game.EXE"}}
launcher := prefix{programs: map[string]bool{"launcher.exe": true}}
arguments := manager.helper_arguments(manager.controller_only(launcher))
if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" {
t.Fatalf("launcher arguments = %v", arguments)
}
launcher.programs["game.exe"] = true
if !manager.controller_only(launcher) {
t.Fatal("game did not select controller-only")
}
arguments = manager.helper_arguments(true)
if arguments[len(arguments)-1] != "--no-dialog-reader" {
t.Fatalf("game arguments = %v", arguments)
}
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
if !(&Manager{}).controller_only(prefix{}) {
t.Fatal("environment override ignored")
}
}
func TestProtonInfrastructureDoesNotKeepHelperAlive(t *testing.T) {
for _, program := range []string{`C:\windows\system32\steam.exe`, `C:\windows\syswow64\steam.exe`, "xalia.exe", "conhost.exe"} {
if applicationProcess(program) {
t.Fatalf("infrastructure counted as app: %s", program)
}
}
for _, program := range []string{`C:\Program Files\Steam\steam.exe`, "Game.exe", "explorer.exe"} {
if !applicationProcess(program) {
t.Fatalf("app ignored: %s", program)
}
}
}
func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) {
root := t.TempDir()
output := filepath.Join(root, "environment")
helper := filepath.Join(root, "helper")
write_helper_file(t, helper, "#!/bin/sh\nenv > \"$TEST_WINE_ENV\"\n")
t.Setenv("TEST_WINE_ENV", output)
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/reader/bus")
manager := New(nil)
manager.helper = helper
child := &process{}
source := prefix{path: root, environment: map[string]string{"WINEESYNC": "1", "WINEFSYNC": "1", "XAUTHORITY": "/fixture/auth", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/pressure-vessel/bus"}}
if err := manager.launch(child, source, time.Now()); err != nil {
t.Fatal(err)
}
select {
case <-child.done:
case <-time.After(3 * time.Second):
stop(child)
t.Fatal("helper did not exit")
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
for _, value := range []string{"WINEESYNC=1\n", "WINEFSYNC=1\n", "XAUTHORITY=/fixture/auth\n", "DBUS_SESSION_BUS_ADDRESS=unix:path=/reader/bus\n"} {
if !bytes.Contains(data, []byte(value)) {
t.Fatalf("missing %q", value)
}
}
}
func TestDiscoverAggregatesGamesAndEscalatesHelper(t *testing.T) {
root := t.TempDir()
procRoot := filepath.Join(root, "proc")
loader := filepath.Join(root, "wine")
write_helper_file(t, loader, "loader")
makeProcess := func(pid, program string) {
t.Helper()
base := filepath.Join(procRoot, pid)
write_helper_file(t, filepath.Join(base, "cmdline"), program+"\x00")
write_helper_file(t, filepath.Join(base, "environ"), "WINEPREFIX="+root+"\x00")
if err := os.Symlink(loader, filepath.Join(base, "exe")); err != nil {
t.Fatal(err)
}
}
makeProcess("101", "launcher.exe")
helper := filepath.Join(root, "helper")
write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n")
manager := New(nil)
manager.helper, manager.procRoot = helper, procRoot
manager.ExcludedPrograms = []string{"GAME.exe"}
active := map[string]*process{}
defer func() {
for _, child := range active {
stop(child)
}
}()
manager.poll(active)
child := active[root]
if child == nil || child.cmd == nil || child.controllerOnly {
t.Fatal("launcher helper did not start normally")
}
originalPID := child.cmd.Process.Pid
makeProcess("102", "Game.exe")
discovered := discover(procRoot, uint32(os.Getuid()))
if len(discovered[root].programs) != 2 {
t.Fatalf("programs = %v", discovered[root].programs)
}
manager.poll(active)
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
t.Fatal("helper did not switch to controller-only")
}
pid := child.cmd.Process.Pid
if err := os.RemoveAll(filepath.Join(procRoot, "102")); err != nil {
t.Fatal(err)
}
manager.poll(active)
if !child.controllerOnly || child.cmd.Process.Pid != pid {
t.Fatal("controller-only helper downgraded after game disappeared")
}
}
+8
View File
@@ -152,6 +152,9 @@ def inside():
EchoVoice={"Family": {"Name": "Spanish test voice", "Lang": "es"}})
if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1":
initial["Tentacles"] = {"Entries": [], "Suppressed": False}
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
from native_tentacle_updates import update_snapshot
initial["Tentacles"] = update_snapshot()
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True},
"State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False}
@@ -458,6 +461,11 @@ def inside():
check_page(pyatspi, process, window, nodes, named, walk, focus, key,
request, send, initial, application)
return
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
from native_tentacle_updates import check_page
check_page(pyatspi, process, window, named, walk, focus, key,
request, send, initial)
return
if os.environ.get("CTHULHU_PREFS_OCR_ONLY") == "1":
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
assert tabs.querySelection().selectChild(8), "cannot select OCR tab"
+77
View File
@@ -0,0 +1,77 @@
"""Private GTK update confirmation and keyboard workflow; no installed packages."""
import select
from native_x11 import wait_for
def update_snapshot():
repositoryUrl = "https://example.org/tentacles"
return {
"Entries": [{"Entry": {
"ID": "probe", "LocalID": "echo", "Name": "Update probe",
"Repository": repositoryUrl, "RepositoryID": "example.source",
"Version": "1.0", "Enabled": True, "Presentation": True,
}, "State": "active"}],
"Repositories": [{"URL": repositoryUrl, "ID": "example.source", "Name": "Example",
"Tentacles": [{"ID": "echo", "Name": "Update probe", "Version": "1.1",
"Capabilities": ["presentation", "interaction"]}]}],
"Updates": [{"ID": "probe", "LocalID": "echo", "Name": "Update probe",
"InstalledVersion": "1.0", "AvailableVersion": "1.1",
"RepositoryURL": repositoryUrl, "RepositoryID": "example.source"}],
}
def check_page(pyatspi, process, window, named, walk, focus, key, request, send, initial):
tab = next(node for node in walk(window)
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB)
assert tab.parent.querySelection().selectChild(tab.getIndexInParent()), "cannot select Tentacles tab"
table = next(node for node in walk(window)
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE)
focus(table)
key("Home")
update = named("Update")
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "Update did not enable")
# Tab from the table skips disabled Install and reaches Update.
key("Tab")
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not reach Update")
key("Tab")
wait_for(lambda: named("Settings").getState().contains(pyatspi.STATE_FOCUSED), "Update trapped Tab")
key("shift+Tab")
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not reach Update")
def confirmation():
for app in pyatspi.Registry.getDesktop(0):
for node in app:
if node and node.getRole() in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT):
if any("Current capabilities:" in child.name for child in walk(node)):
return node
return None
key("space")
dialog = wait_for(confirmation, "keyboard activation did not open confirmation")
message = " ".join(node.name for node in walk(dialog))
assert "version 1.0 to version 1.1" in message, message
assert "Current capabilities: presentation." in message, message
assert "Repository declares: presentation, interaction." in message, message
cancel = next(node for node in walk(dialog) if node.name == "Cancel")
focus(cancel)
key("space")
wait_for(lambda: not confirmation(), "Cancel did not close confirmation")
assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel sent a management request"
# The native button action uses the same path as keyboard activation.
update.queryAction().doAction(0)
dialog = wait_for(confirmation, "button action did not open confirmation")
accept = next(node for node in walk(dialog)
if node.name == "Update" and node.getRole() == pyatspi.ROLE_PUSH_BUTTON)
focus(accept)
key("Return")
value = request()
assert value["Tentacle"] == {"Action": "upgrade", "ID": "echo", "Entry": None,
"URL": "https://example.org/tentacles"}, value["Tentacle"]
send(dict(initial, Message="Update accepted."))
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "response left controls busy")
focus(named("Cancel"))
key("space")
assert process.wait(timeout=5) == 0
print("PASS: Update Tab/Shift+Tab, keyboard/button activation, versions/capabilities, Cancel, upgrade request")
+20 -2
View File
@@ -2,15 +2,33 @@
// but remains in Xlib's queue when the Go owner gives the keyboard back.
#include "commands_native.h"
#include <X11/keysym.h>
#include <X11/XKBlib.h>
#include <X11/extensions/XTest.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
int error = 0, failed = 0;
CommandDevice *device = open_commands(1, 0, 0, &error);
int capsHandoff = argc == 2 && strcmp(argv[1], "caps-handoff") == 0;
CommandDevice *device = open_commands(1, 0, capsHandoff, &error);
if (!device) { fprintf(stderr, "open: %d\n", error); return 1; }
if (argc == 2 && strcmp(argv[1], "queued") == 0) {
if (capsHandoff) {
set_command_keys(device, 0);
// A terminal reader changes the real lock while Cthulhu is suspended.
XkbLockModifiers(device->display, XkbUseCoreKbd, LockMask, LockMask);
XSync(device->display, False);
if (!(command_lock_state(device) & 1)) {
fprintf(stderr, "suspended lock read used stale reader state\n");
failed = 1;
}
set_command_keys(device, 1);
if (!device->capsLocked) {
fprintf(stderr, "resumed reader did not adopt external Caps Lock state\n");
failed = 1;
}
XkbLockModifiers(device->display, XkbUseCoreKbd, LockMask, 0);
XSync(device->display, False);
} else if (argc == 2 && strcmp(argv[1], "queued") == 0) {
if (XGrabKeyboard(device->display, device->root, False,
GrabModeAsync, GrabModeAsync, CurrentTime) != GrabSuccess) return 1;
device->active = 1;