diff --git a/AGENTS.md b/AGENTS.md index 13ce594..f30e925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Use `gofmt`, exported PascalCase identifiers, private camelCase identifiers, and ## Behavior & Accessibility Safeguards -Read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed safeguards. Compare accepted behavior with Python on `legacy`; use newer Orca as a Mozilla reference. Preserve unrelated work and active diagnostic logs; obtain authorization before replacing a running reader. +Read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed safeguards. Compare accepted behavior with Python on `legacy`; use current Orca as the primary reference for Mozilla and LibreOffice support. Follow [LIBREOFFICE_SUPPORT.md](LIBREOFFICE_SUPPORT.md) for Office integration boundaries. Preserve unrelated work and active diagnostic logs; obtain authorization before replacing a running reader. Fix regressions in the shared subsystem when appropriate. Identify state ownership, lifetime, reset, and cancellation; keep native callbacks and input/focus handling bounded. Preserve Xorg correctness while allowing capability-based Wayland degradation. Keep Tentacles external and responsive. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64becb2..ae9077d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Inspect relevant settings, bindings, speech, Braille, and toolkit behavior befor git show legacy:src/cthulhu/settings.py ``` -Use the newer Orca implementation as the first reference for Firefox and Thunderbird when available. Reference implementations guide behavior; do not mechanically copy Python class or plugin boundaries. Preserve accepted behavior unless the user chooses a deliberate difference, and describe that difference. Do not modify reference checkouts or installed readers without authorization. +Use the newer Orca implementation as the first reference for Firefox, Thunderbird, and LibreOffice when available. [LibreOffice support](LIBREOFFICE_SUPPORT.md) describes its integration boundaries and remaining acceptance checks. Reference implementations guide behavior; do not mechanically copy Python class or plugin boundaries. Preserve accepted behavior unless the user chooses a deliberate difference, and describe that difference. Do not modify reference checkouts or installed readers without authorization. Use complete messages and self-contained labels with placeholders for variable content. Keep internal states, commands, and settings identifiers independent of user-facing wording. Preserve deliberate speech/Braille segmentation, verbosity, and separate role/state announcements. Apply these rules within the change; adding translation infrastructure or sweeping existing strings requires separate scope. diff --git a/LIBREOFFICE_SUPPORT.md b/LIBREOFFICE_SUPPORT.md new file mode 100644 index 0000000..1ed22d3 --- /dev/null +++ b/LIBREOFFICE_SUPPORT.md @@ -0,0 +1,150 @@ +# LibreOffice integration + +Current Orca is the primary behavioral reference for LibreOffice. Python +Cthulhu remains available on the `legacy` branch for historical comparisons: +`git show legacy:src/cthulhu/scripts/apps/soffice/script.py`. +Do not switch the active Go checkout to inspect that reference. + +## Architecture + +Application handling runs after screen-lock and foreground validation, before +generic focus and text filtering. `internal/app/application_behavior.go` +routes copied accessibility events by verified provider process identity. +The LibreOffice handler lives in `internal/app/libreoffice.go`. +It can normalize an event, request focus adoption, or suppress presentation. +The reader retains window ownership, input policy, pending-output cancellation, +review, and speech/Braille delivery. Ordinary applications keep their existing +event path. Steam's existing integration remains in place. + +`internal/atspi/libreoffice_native.go` captures bounded Calc metadata on the +native owner thread. It does not enumerate a sheet. The controller and +presentation code never retain native objects or perform provider queries. +`internal/presentation/libreoffice.go` owns the corresponding speech +formatting; LibreOffice Braille formatting stays beside its controller. + +The first implementation has no independent mutable application state: +logical focus belongs to the existing event loop. Future selections and +dynamic headers must be keyed by application bus, document, and sheet identity. +Retire them on document/sheet destruction or application exit. Focus loss +must cancel pending output while preserving document header choices; shutdown +and screen lock must retire pending operations. Add command routing when the +first real Office command requires it, using the existing input machinery. +Application code must not bypass security/foreground gates or manage its own +speech connection, key grabs, or background event loop. + +Keep generic table operations and genuinely shared fixes in their existing +subsystems. Keep verified LibreOffice exceptions in the dedicated files. +Adding later Office features should extend these boundaries, without +reorganizing unrelated reader code or building a speculative plugin framework. + +## First slice + +- Calc active-cell events establish logical focus, including after editing + and when the sheet changes. +- Cell contents and addresses are separate. Normal speech gives the value, + then the address; an empty value gives “blank.” Only-displayed-text mode + omits the address and the synthetic blank label. +- Recorded Braille preserves value spacing followed by the address. A cell + component is not an editable text route. +- F2 resolves a temporary editor's focused paragraph when available. + If the paragraph appears later, its first owned text/caret event establishes + editor focus before generic filtering. +- Post-editing sheet focus is adopted silently. Spreadsheet capacity and + cell/container state chatter are omitted as in Orca. +- Ancestor focus claims are suppressed when captured ancestry identifies the + existing focused spreadsheet object. +- Non-editor caret events do not displace a focused cell. Selection events do + not promote the first selected cell of a range into logical focus. + +References inspected: Orca commit `cd58a88ed` (2026-10-14), +`scripts/apps/soffice/script.py`, `script_utilities.py`, +`speech_generator.py`, `braille_generator.py`, and shared +`speech_generator.py` / `ax_utilities_table.py`. + +## Next slices + +1. Formula presence and an on-demand formula command, using current Orca's + `where_am_i_presenter.py:present_cell_formula` and cell attributes. +2. Selected/unselected ranges, rows, columns, and Select All. Represent ranges + compactly; do not expand every selected cell as Orca's current comparison + does. Test very large and disjoint selections. +3. Dynamic row/column headers, sheet-scoped state, preferences and bindings. + Inspect current Orca's shared table navigator rather than copying only + the soffice directory. Spreadsheet full-row reading also needs a bounded, + explicitly defined extent; the generic setting is not full Calc support. +4. Writer paragraph navigation, selection, Say All, spell-check presentation, + and Braille panning. Compare each actual workflow with current Orca. + +Merged cells, overflow announcements, large-sheet responsiveness, other +LibreOffice backends, and independent-workbook acceptance remain separate +checks. This first slice does not establish complete Office support. + +## Verification + +```sh +GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1 +/usr/bin/python3 tests/native_libreoffice.py +python3 tests/native_atspi.py --table-only +python3 tests/native_x11.py +``` + +The Calc harness launches installed LibreOffice with a disposable profile, +document, virtual X11 display, and private session/accessibility buses. +It exercises the production native adapter and event loop; recorded speech +and Braille are asserted for cell movement and blanks, F2 typing, Escape +cancellation, Enter commit, caret movement inside an existing numeric cell, +and switching between two sheets. +No active reader is replaced and no normal Office profile is changed. + +These automated checks do not establish audible speech, physical input, or +physical Braille. Before submission, review the diff and test a rebuilt binary: + +1. In a disposable Calc workbook, populate text, numbers, formulas and blanks. + Arrow between them and listen for the value followed by its address. +2. Press F2, edit, cancel with Escape, then edit and commit with Enter. Check + character/word navigation in an existing cell editor and continued arrows. +3. Switch sheets and switch between two workbooks. Return from menus, dialogs, + the formula bar, and another application; check logical focus recovery. +4. Check the cell value/address, spacing, editor cursor and return to cell + display on physical Braille. Text routing applies inside the editor. +5. Repeat ordinary GTK table movement/multiselection, browser typing and table + reading, and Steam navigation. Check lock/unlock and stale/background events. + +Human review and testing remain required by `CONTRIBUTING.md`. Passing this +fixture alone is not sufficient for submission or live accessibility acceptance. + +Initial verification against LibreOffice 26.2.6.3 passed the Calc harness, +affected-package race tests, vet/build, GTK table/multiselection checks, and +Xorg input checks. Browser tab and table checks also passed. Browser editing +failed on an extra title announcement both here and on untouched commit +`8e76d23`. Browser modes initially failed to focus a heading after reload, +then passed on the baseline and on a rerun here; the cause of that first +failure was not established. Physical acceptance remains pending. + +## Calc startup focus diagnosis + +A normal-profile startup under i3 can leave Calc accepting arrow keys while +omitting active-cell accessibility events. In the observed case, cell events +were followed by focus claims from the floating Form Design and Form Controls +toolbars; subsequent arrows produced formula-bar text updates but no new cell +events. A separate AT-SPI observer reproduced the missing events without +Cthulhu. The affected workbook worked with a fresh profile, and a generic +workbook reproduced the failure with a disposable copy of the normal profile. +This establishes a profile-dependent LibreOffice focus/event problem; the +precise profile setting responsible has not been isolated. + +Press **Ctrl+F6** to return focus to the document, then retry the arrows. +This directly restored active-cell events in the isolated reproduction. +LibreOffice documents this shortcut in its +[Calc accessibility keyboard guide](https://help.libreoffice.org/latest/en-GB/text/scalc/guide/keyboard.html). +Automatic focus stealing would interfere with intentional toolbar use and +is not part of the reader's handling. + +The native Calc test focuses its window once and uses ordinary key sequences +afterward. It also checks F6 followed by Ctrl+F6 and resumed cell speech/Braille. +Its fresh-profile virtual display does not cover this normal-profile startup +failure. The same production test also passed under private i3 with the copied +normal profile after Ctrl+F6 recovery, asserting recorded cell speech and +Braille along with editing and sheet switching. Verify startup and recovery +under the actual window manager, plus audible speech and physical Braille, +separately. diff --git a/README.md b/README.md index 38b7c08..b16803e 100644 --- a/README.md +++ b/README.md @@ -4,5 +4,19 @@ Cthulhu is a Linux screen reader written in Go. - [Repository Guidelines](AGENTS.md): layout, commands, and working safeguards. - [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy. +- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and 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). + +## Wine Access + +Install the separate `wine-access` helper for Wine application accessibility. +Cthulhu checks `CTHULHU_WINE_ACCESS_HELPER`, then +`~/.local/libexec/wine-access/wine-access.exe`, +`/usr/local/libexec/wine-access/wine-access.exe`, +`/usr/libexec/wine-access/wine-access.exe`, and `wine-access.exe` on `PATH`. +The existing `cthulhu-wine-access.exe` lookup remains supported: the old user +location follows the new user location, the old `/usr` location follows the new +system locations, and the old name on `PATH` follows the new name. +Restart Cthulhu after changing the selected helper. Keep the launcher, its +`.exe.so` sidecar, and the native hooks together in the installed directory. diff --git a/internal/app/application_behavior.go b/internal/app/application_behavior.go new file mode 100644 index 0000000..029b004 --- /dev/null +++ b/internal/app/application_behavior.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/atspi" + +type applicationEventResult struct { + event atspi.Event + adoptFocus bool + suppress bool + resetPresentation bool +} + +// prepareApplicationEvent runs after security/foreground checks and before +// generic focus and text filters. Application policy receives copied semantics; +// the reader retains ownership, review, cancellation, and output services. +// The first handler is stateless: document-specific state can be owned here +// when a concrete feature needs it, rather than hidden in native callbacks. +func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) { + result := applicationEventResult{event: event} + if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) { + result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner) + } + if result.adoptFocus { + if handler.focusGate.Suspended() { + return event, false + } + state := handler.state + if !state.owner.follow(result.event) { + return event, true + } + state.pending.caret.clear() + state.focusedID = result.event.Source.ID() + state.current = result.event.Source + state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{} + if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" { + // A late-created editor has no ordinary focus event to reset + // row history. Returning to its old cell is a fresh focus. + state.lastTable, state.lastTableCell = atspi.ObjectID{}, atspi.ObjectID{} + } + state.selection.reset(result.event.Source) + if handler.objectReview != nil { + handler.objectReview.SyncFocus(result.event.Source.ID()) + } + } + if result.resetPresentation { + handler.state.pending.caret.clear() + handler.state.lastSpokenPath, handler.state.lastSpokenText, handler.state.lastSound = "", "", "" + } + if result.suppress { + handler.logger.Debug("skipped application-specific accessibility event", "type", event.Type, + "application_process", event.Source.ApplicationProcess, "role", event.Source.Role) + } + return result.event, result.suppress +} diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index bae52dc..195feee 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -160,6 +160,9 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption } func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { + if frame, handled := libreOfficeBrailleFrame(s, options); handled { + return frame + } name, role := strings.TrimSpace(s.Name), s.Role if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown { label := name diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index cc61efa..2f5367a 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -135,6 +135,11 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { } return } + var applicationSuppressed bool + event, applicationSuppressed = handler.prepareApplicationEvent(event) + if applicationSuppressed { + return + } if handler.handleLifecycle(event) { return } diff --git a/internal/app/libreoffice.go b/internal/app/libreoffice.go new file mode 100644 index 0000000..3e3b94a --- /dev/null +++ b/internal/app/libreoffice.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/config" +) + +// prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy. +// A Calc active cell is the logical focus even when editing destroyed the old +// focus or a sheet switch changed its managed container (LO bug 161444). +func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner windowOwner) applicationEventResult { + result := applicationEventResult{event: event} + context := event.Source.LibreOffice + if !atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) || + event.Source.Defunct || !event.Source.ID().Valid() || + owner.window.Valid() && (eventWindow(event) != owner.window || event.Source.BusName != owner.applicationBus) { + return result + } + if isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && + current.LibreOffice != nil && current.LibreOffice.InSpreadsheet { + // LibreOffice also claims focus for ancestors of the real focused + // object. Orca ignores these; copied ancestry is enough to identify + // them without another native tree query. + for _, ancestor := range current.BrailleContext { + if ancestor.ID().Valid() && ancestor.ID() == event.Source.ID() { + result.suppress = true + return result + } + } + } + if context != nil && context.InSpreadsheet && context.Cell && event.Source.Role == "table cell" && + event.Type == atspi.ActiveDescendantEvent && + (event.Source.Focused || context.ContainerFocused) { + // Preserve NativeType/OriginPath for diagnostics. Ordinary focus + // handling now owns review, duplicate suppression, and Braille. + if result.event.NativeType == "" { + result.event.NativeType = event.Type + } + result.event.Type = atspi.FocusEvent + } + if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent { + // Selection can name the first cell of a range rather than the + // active cell. Movement follows active-descendant events; range + // presentation belongs to the later selection-support slice. + result.suppress = true + } + if context != nil && context.InSpreadsheet && event.Source.Role == "table" && + isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && + current.LibreOffice != nil && + (current.LibreOffice.CellEditor || current.LibreOffice.Cell && current.TablePath == event.Source.Path) { + // Orca treats this as the sheet reclaiming focus after editing. + // Own it silently; the following active cell supplies presentation. + result.adoptFocus, result.suppress = true, true + } + if context != nil && context.Cell && isFocusGainEvent(result.event) && current.ID() != event.Source.ID() { + // Returning to the cell after typing must speak it even if its + // value is unchanged; typing uses a separate presentation history. + result.resetPresentation = true + } + if context != nil && context.CellEditor && event.Source.Role == "paragraph" && + (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) && + owner.window.Valid() && event.Source.ID() != current.ID() && + (event.Source.Focused || context.EditorParent == current.ID() && current.LibreOffice != nil && current.LibreOffice.CellEditor) { + // Empty-cell F2 can focus a panel before its paragraph exists. + // Admit that editor's first mutation before generic text filtering. + result.adoptFocus = true + } + if event.Type == atspi.CaretEvent && current.LibreOffice != nil && current.LibreOffice.Cell && + (context == nil || !context.CellEditor) { + // Formula-bar/background caret events must not displace the cell. + result.suppress = true + } + return result +} + +func libreOfficeBrailleFrame(snapshot atspi.Snapshot, options config.BrailleOptions) (braille.Frame, bool) { + context := snapshot.LibreOffice + if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Role != "table cell" || + context == nil || !context.InSpreadsheet || !context.Cell { + return braille.Frame{}, false + } + // Orca presents the value followed by its cell address. This is a + // component, not an editable-text route; cell focus has no text caret. + return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0, + WordWrap: options.WordWrap}, true +} diff --git a/internal/app/libreoffice_native_test.go b/internal/app/libreoffice_native_test.go new file mode 100644 index 0000000..3c7626d --- /dev/null +++ b/internal/app/libreoffice_native_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bytes" + "context" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +type calcFrameQueue struct{ frames chan braille.Frame } + +func (queue calcFrameQueue) EnqueueFrame(frame braille.Frame) { queue.frames <- frame } + +// This runs the production native adapter and event loop against real Calc. +// Speech/Braille are recorded queues, not audible or physical display proof. +func TestNativeLibreOfficeCalc(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_LIBREOFFICE") != "1" { + t.Skip("requires tests/native_libreoffice.py") + } + window := os.Getenv("CTHULHU_CALC_WINDOW") + if window == "" { + t.Fatal("Calc window missing") + } + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := adapter.Stop(stopCtx); err != nil { + t.Errorf("stop adapter: %v", err) + } + }() + speech := &fakeSpeechQueue{spoken: make(chan string, 256)} + frames := make(chan braille.Frame, 256) + done := make(chan error, 1) + var logBuffer bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) + options := presentation.DefaultOptions() + options.EnableEchoByCharacter = true + go func() { + done <- processEvents(ctx, logger, outputQueue{speech: speech, braille: calcFrameQueue{frames}}, adapter, 0, options) + }() + defer func() { + cancel() + <-done + if t.Failed() { + t.Log(logBuffer.String()) + } + }() + press := func(keys ...string) { + t.Helper() + args := append([]string{"key", "--clearmodifiers"}, keys...) + if err := exec.CommandContext(ctx, "xdotool", args...).Run(); err != nil { + t.Fatal(err) + } + } + waitSpeech := func(want string) { + t.Helper() + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + for { + select { + case got := <-speech.spoken: + t.Logf("Calc speech: %q", got) + if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") { + t.Fatalf("Calc capacity/state chatter: %q", got) + } + if got == want { + return + } + case <-timeout.C: + t.Fatalf("missing Calc speech %q", want) + } + } + } + waitFrame := func(suffix string) { + t.Helper() + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + for { + select { + case frame := <-frames: + if strings.HasSuffix(frame.Text, suffix) { + return + } + case <-timeout.C: + t.Fatalf("missing Calc Braille ending %q", suffix) + } + } + } + enterEditor := func(coordinate string) { + t.Helper() + drain: + for { + select { + case <-frames: + default: + break drain + } + } + press("F2") + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + for { + select { + case frame := <-frames: + if strings.Contains(frame.Text, "Cell "+coordinate) { + return + } + case <-timeout.C: + t.Fatal("Calc did not expose its temporary cell editor") + } + } + } + // Focus once. Refocusing before every key sequence can hide failures + // to retain or recover Calc's document focus during normal keyboard use. + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + press("Escape", "ctrl+Home", "Right") + waitSpeech("42, B1") + waitFrame("42 B1") + press("Down") + waitSpeech("Beta, B2") + press("Left") + waitSpeech("blank, A2") + waitFrame("A2") + enterEditor("A2") + press("x") + waitSpeech("x") + press("Escape") + waitSpeech("blank, A2") + enterEditor("A2") + press("y") + waitSpeech("y") + press("Return") + waitSpeech("blank, A3") + press("Up") + waitSpeech("y, A2") + press("ctrl+Page_Down", "ctrl+Home") + waitSpeech("Gamma, A1") + press("ctrl+Page_Up", "ctrl+Home") + waitSpeech("Alpha, A1") + press("Right") + waitSpeech("42, B1") + enterEditor("B1") + press("Left") + waitSpeech("2") + press("Escape") + waitSpeech("42, B1") + press("F6") + press("ctrl+F6", "Down") + waitSpeech("Beta, B2") + waitFrame("Beta B2") +} diff --git a/internal/app/libreoffice_test.go b/internal/app/libreoffice_test.go new file mode 100644 index 0000000..b4ac603 --- /dev/null +++ b/internal/app/libreoffice_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" +) + +func calcTestEvent(eventType, path, value, coordinate string) atspi.Event { + source := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "table cell", Name: coordinate, + Text: value, ApplicationProcess: "soffice.bin", Focused: true, + TablePath: "/sheet", TablePositionKnown: true, + LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: coordinate}} + event := atspi.NewEvent(eventType, source) + event.NativeType = eventType + event.Window = atspi.NewObjectID(source.BusName, "/window") + event.OriginPath = "/sheet" + return event +} + +func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) { + state := eventLoopState{} + state.presentationOptions = presentation.DefaultOptions() + state.presentationOptions.EnableEchoByCharacter = true + speech := &fakeSpeechQueue{spoken: make(chan string, 32)} + handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler), + speech: speech, adapter: &fakeAdapter{}, clearOwnedFocus: func() {}, syncForeground: func() {}} + presenter := eventPresenter{state: &state, logger: handler.logger, speech: speech} + handler.speak = presenter.speak + handler.titles = &windowTitleController{state: &state, speech: speech, logger: handler.logger} + handler.webScripts = &webScriptController{speech: speech} + expectSpeech := func(want string) { + t.Helper() + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("Calc speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing Calc speech %q", want) + } + } + first := calcTestEvent(atspi.ActiveDescendantEvent, "/cell1", "42", "B1") + first.Source.TableColumnIndex = 1 + handler.handle(first) + expectSpeech("42, B1") + ancestor := first + ancestor.Type = atspi.FocusEvent + ancestor.Source.Path, ancestor.Source.Role, ancestor.Source.Name = "/root_pane", "root pane", "Example" + ancestor.Source.LibreOffice = nil + state.current.BrailleContext = []atspi.Snapshot{ancestor.Source} + handler.handle(ancestor) + if state.current.ID() != first.Source.ID() || len(speech.spoken) != 0 { + t.Fatal("ancestor focus displaced the active cell") + } + handler.handle(first) + if len(speech.spoken) != 0 { + t.Fatal("repeated cell descendant was spoken") + } + blank := calcTestEvent(atspi.ActiveDescendantEvent, "/cell2", "", "A2") + blank.Source.TableRowIndex = 1 + handler.handle(blank) + expectSpeech("blank, A2") + backgroundCaret := blank + backgroundCaret.Type = atspi.CaretEvent + backgroundCaret.Source.Path = "/input_line" + backgroundCaret.Source.Role = "paragraph" + backgroundCaret.Source.Text = "unrelated" + backgroundCaret.Source.LibreOffice = nil + handler.handle(backgroundCaret) + if state.current.ID() != blank.Source.ID() || len(speech.spoken) != 0 { + t.Fatal("formula-bar caret displaced cell focus") + } + editor := blank + editor.Type = atspi.TextInsertedEvent + editor.Source.Path, editor.Source.Role, editor.Source.Name = "/editor", "paragraph", "" + editor.Source.Editable = true + editor.Source.Text, editor.Payload = "x", "x" + editor.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, CellEditor: true} + handler.handle(editor) + if state.current.ID() != editor.Source.ID() || state.focusedID != editor.Source.ID() { + t.Fatal("first editor mutation was filtered before focus adoption") + } + expectSpeech("x") + table := editor + table.Type = atspi.FocusEvent + table.Source.Path, table.Source.Role, table.Source.Name = "/sheet", "table", "Sheet Example" + table.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true} + handler.handle(table) + if state.current.ID() != table.Source.ID() || len(speech.spoken) != 0 { + t.Fatal("post-edit table focus was not owned silently") + } + handler.handle(blank) + expectSpeech("blank, A2") + otherSheet := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "Gamma", "A1") + otherSheet.OriginPath = "/other_sheet" + otherSheet.Source.TablePath = "/other_sheet" + handler.handle(otherSheet) + expectSpeech("Gamma, A1") + selection := blank + selection.Type = atspi.SelectionChangedEvent + handler.handle(selection) + if state.current.ID() != otherSheet.Source.ID() || len(speech.spoken) != 0 { + t.Fatal("range selection displaced the active cell") + } +} + +func TestLibreOfficePolicyCannotAdoptAnotherWindowOrProcess(t *testing.T) { + current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1") + owner := windowOwner{window: current.Window, applicationBus: current.Source.BusName} + for _, change := range []func(*atspi.Event){ + func(event *atspi.Event) { event.Window.Path = "/other_window" }, + func(event *atspi.Event) { event.Source.ApplicationProcess = "chromium" }, + func(event *atspi.Event) { event.Source.Focused = false }, + func(event *atspi.Event) { event.Source.Defunct = true }, + } { + event := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "wrong", "C1") + change(&event) + result := prepareLibreOfficeEvent(event, current.Source, owner) + if result.event.Type != atspi.ActiveDescendantEvent || result.adoptFocus { + t.Fatalf("unowned cell became focus: %+v", result) + } + } +} + +func TestLibreOfficeBraillePreservesSpacingAndHasNoEditorRoute(t *testing.T) { + event := calcTestEvent(atspi.FocusEvent, "/cell", "é 猫", "B3") + frame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille) + if frame.Text != "é 猫 B3" || frame.Cursor != 0 || len(frame.Routes) != 0 { + t.Fatalf("Calc Braille = %+v", frame) + } +} + +func TestLibreOfficeRoutingHonorsLockAndForegroundBeforePolicy(t *testing.T) { + for _, locked := range []bool{true, false} { + t.Run(map[bool]string{true: "locked", false: "stale foreground"}[locked], func(t *testing.T) { + current := calcTestEvent(atspi.FocusEvent, "/current", "42", "B1") + state := eventLoopState{} + state.current, state.focusedID = current.Source, current.Source.ID() + state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName} + state.foregroundState = atspi.ForegroundState{Epoch: 2} + state.lastSpokenText = "unchanged" + gate := newFocusGate(nil) + gate.secure.Store(locked) + speech := &fakeSpeechQueue{spoken: make(chan string, 4)} + handler := accessibilityEventHandler{state: &state, focusGate: gate, speech: speech, + logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, + foreground: &ocrTestForeground{state: state.foregroundState}, + syncForeground: func() {}, webScripts: &webScriptController{}} + event := calcTestEvent(atspi.ActiveDescendantEvent, "/new_cell", "wrong", "C1") + event.Foreground.Epoch = 1 + handler.handle(event) + if state.current.ID() != current.Source.ID() || state.lastSpokenText != "unchanged" || len(speech.spoken) != 0 { + t.Fatal("LibreOffice policy ran before security/foreground rejection") + } + }) + } +} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 086acb7..e682b2f 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -301,6 +301,8 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A focusEvent.Source = snapshot } if !focusEvent.ForegroundMismatch { + captureLibreOfficeContext(target, &snapshot) + focusEvent.Source = snapshot if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent { snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target) for _, ancestor := range snapshot.SpeechContext { @@ -415,7 +417,11 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { return } snapshotStarted := time.Now() - target, ownedTarget := selectNativeEventTarget(event, eventType) + target := adapter.libreOfficeFocusTarget(event, eventType) + ownedTarget := target != nil + if target == nil { + target, ownedTarget = selectNativeEventTarget(event, eventType) + } if target == nil { return } diff --git a/internal/atspi/libreoffice.go b/internal/atspi/libreoffice.go new file mode 100644 index 0000000..fecaef1 --- /dev/null +++ b/internal/atspi/libreoffice.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +// LibreOfficeContext is bounded, copied metadata from the native owner. It +// describes Calc's cell and temporary editor objects, without native handles. +// Presentation and application policy must not query the provider themselves. +type LibreOfficeContext struct { + InSpreadsheet bool + Cell bool + CellEditor bool + EditorParent ObjectID + ContainerFocused bool + CellText string + Coordinates string +} + +// IsLibreOfficeProcess uses /proc identity verified by the adapter. An +// accessible name or document title must never enable application policy. +func IsLibreOfficeProcess(process string) bool { + return process == "soffice.bin" +} diff --git a/internal/atspi/libreoffice_native.go b/internal/atspi/libreoffice_native.go new file mode 100644 index 0000000..e5fe042 --- /dev/null +++ b/internal/atspi/libreoffice_native.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +import "unsafe" + +// libreOfficeFocusTarget resolves only a Calc editor's direct text children. +// F2 focuses its temporary panel; the paragraph can appear later, on typing. +// The callback owns and releases the returned reference. No sheet is walked. +func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible { + if eventType != FocusEvent && eventType != FocusedStateEvent || + !IsLibreOfficeProcess(adapter.applicationProcess(event.source)) { + return nil + } + role := C.atspi_accessible_get_role(event.source, nil) + if role == C.ATSPI_ROLE_TABLE { + parent := C.atspi_accessible_get_parent(event.source, nil) + if parent != nil { + if C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET { + // Orca clears stale table state around Calc focus transitions. + C.atspi_accessible_clear_cache_single(event.source) + } + C.g_object_unref(C.gpointer(parent)) + } + return nil + } + if role != C.ATSPI_ROLE_PANEL && role != C.ATSPI_ROLE_EXTENDED { + return nil + } + parent := C.atspi_accessible_get_parent(event.source, nil) + if parent == nil { + return nil + } + spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET + C.g_object_unref(C.gpointer(parent)) + if !spreadsheet { + return nil + } + count := min(int(C.atspi_accessible_get_child_count(event.source, nil)), 4) + for index := 0; index < count; index++ { + child := C.atspi_accessible_get_child_at_index(event.source, C.gint(index), nil) + if child == nil { + continue + } + states := C.atspi_accessible_get_state_set(child) + focused := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 + if states != nil { + C.g_object_unref(C.gpointer(states)) + } + if focused && C.atspi_accessible_get_role(child, nil) == C.ATSPI_ROLE_PARAGRAPH { + return child + } + C.g_object_unref(C.gpointer(child)) + } + return nil +} + +func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) { + if !IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct { + return + } + switch snapshot.Role { + case "table", "table cell", "paragraph", "panel", "extended": + default: + return + } + context := LibreOfficeContext{} + parentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID) + grandparentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID) + current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) + for depth := 0; current != nil && depth < 32; depth++ { + role := C.atspi_accessible_get_role(current, nil) + if depth == 1 { + parentRole = role + context.EditorParent = nativeObjectID(current) + } + if depth == 2 { + grandparentRole = role + } + if role == C.ATSPI_ROLE_TABLE { + states := C.atspi_accessible_get_state_set(current) + if states != nil { + context.ContainerFocused = C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 + C.g_object_unref(C.gpointer(states)) + } + } + if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET { + context.InSpreadsheet = true + C.g_object_unref(C.gpointer(current)) + current = nil + break + } + if role == C.ATSPI_ROLE_FRAME || role == C.ATSPI_ROLE_DIALOG || role == C.ATSPI_ROLE_APPLICATION { + C.g_object_unref(C.gpointer(current)) + current = nil + break + } + parent := C.atspi_accessible_get_parent(current, nil) + C.g_object_unref(C.gpointer(current)) + current = parent + } + if current != nil { + C.g_object_unref(C.gpointer(current)) + } + if !context.InSpreadsheet { + return + } + context.Cell = snapshot.Role == "table cell" + context.CellEditor = (snapshot.Role == "panel" || snapshot.Role == "extended") && + parentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET || + snapshot.Role == "paragraph" && (parentRole == C.ATSPI_ROLE_PANEL || parentRole == C.ATSPI_ROLE_EXTENDED) && + grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET + if context.Cell { + context.Coordinates = snapshot.Name + attributes := C.atspi_accessible_get_attributes(source, nil) + if attributes != nil { + columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext") + column := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(columnKey)))) + row := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(rowKey)))) + C.free(unsafe.Pointer(columnKey)) + C.free(unsafe.Pointer(rowKey)) + if column != "" && row != "" { + context.Coordinates = sanitizeSnapshotField(column + row) + } + C.g_hash_table_unref(attributes) + } + text := C.atspi_accessible_get_text_iface(source) + if text != nil { + // Calc's value is Text, while Name is the coordinate. Cell caret + // offsets can be invalid, so capture contents independently. + length := max(0, min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength)) + content := C.atspi_text_get_text(text, 0, C.gint(length), nil) + context.CellText = sanitizeTextContext(C.GoString(content)) + C.g_free(C.gpointer(content)) + C.g_object_unref(C.gpointer(text)) + } + } + snapshot.Spreadsheet = true + snapshot.LibreOffice = &context +} diff --git a/internal/atspi/model.go b/internal/atspi/model.go index c5c619a..cab012c 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -36,6 +36,7 @@ type Snapshot struct { SpeechContext []Snapshot // Bounded copied ancestor metadata, without native handles. BrailleContext []Snapshot // Nearest ancestor first, including window/application. Spreadsheet bool + LibreOffice *LibreOfficeContext // BrowserTab identifies browser chrome, excluding page-authored tab widgets. BrowserTab bool // ExplicitName distinguishes an author-supplied link label from a name diff --git a/internal/platform/xorg/window_title.c b/internal/platform/xorg/window_title.c index 6f66a98..fd100c9 100644 --- a/internal/platform/xorg/window_title.c +++ b/internal/platform/xorg/window_title.c @@ -104,7 +104,8 @@ static int usable_child(Display *display, Window window, WindowTitle *result) { const char *identifiers[] = {hint.res_name, hint.res_class}; int infrastructure = 0; for (unsigned int i = 0; i < 2; i++) { - if (identifiers[i] && (!strcasecmp(identifiers[i], "cthulhu-wine-access.exe") || + if (identifiers[i] && (!strcasecmp(identifiers[i], "wine-access.exe") || + !strcasecmp(identifiers[i], "cthulhu-wine-access.exe") || (!strcasecmp(identifiers[i], "nvda.exe") && !strcasecmp(title, "NVDA")))) infrastructure = 1; } diff --git a/internal/platform/xorg/window_title_native_test.go b/internal/platform/xorg/window_title_native_test.go index 643fa1d..330b461 100644 --- a/internal/platform/xorg/window_title_native_test.go +++ b/internal/platform/xorg/window_title_native_test.go @@ -68,7 +68,7 @@ func TestNativeWindowTitleDiscovery(t *testing.T) { foundExample = true case "game.exe": foundGame = application.Wine - case "cthulhu-wine-access.exe", "hidden.exe": + case "wine-access.exe", "cthulhu-wine-access.exe", "hidden.exe": t.Fatalf("infrastructure/hidden Wine window listed: %+v", application) } } diff --git a/internal/presentation/libreoffice.go b/internal/presentation/libreoffice.go new file mode 100644 index 0000000..a11e26b --- /dev/null +++ b/internal/presentation/libreoffice.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "strings" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +// Normalize only the presentation copy. Native names remain coordinates for +// ownership and diagnostics. Orca's soffice generator speaks contents first, +// then the coordinate; an empty Text value is a blank cell, not a missing name. +func libreOfficeFocusSnapshot(snapshot atspi.Snapshot, options Options) atspi.Snapshot { + context := snapshot.LibreOffice + if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || + context == nil || !context.InSpreadsheet { + return snapshot + } + if snapshot.Role == "table" { + // Orca omits table size for spreadsheets. These dimensions describe + // the sheet capacity, not the populated data. + snapshot.TableRowCount, snapshot.TableColumnCount = 0, 0 + snapshot.Multiselectable, snapshot.SensitiveKnown = false, false + return snapshot + } + if context.CellEditor && snapshot.Role == "paragraph" { + snapshot.Role = "" + snapshot.SensitiveKnown = false + return snapshot + } + if snapshot.Role != "table cell" || !context.Cell { + return snapshot + } + snapshot.Name = strings.TrimSpace(context.CellText) + snapshot.Text = context.Coordinates + // Calc cell formatting in Orca omits container selection capabilities + // and insensitive-state chatter; these bits are not cell availability. + snapshot.Multiselectable = false + snapshot.SensitiveKnown = false + if reading_options(options).OnlySpeakDisplayedText { + snapshot.Text = "" + return snapshot + } + if snapshot.Name == "" { + snapshot.Name = "blank" + } + return snapshot +} diff --git a/internal/presentation/libreoffice_test.go b/internal/presentation/libreoffice_test.go new file mode 100644 index 0000000..5814120 --- /dev/null +++ b/internal/presentation/libreoffice_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestLibreOfficeCellValueCoordinatesAndDisplayedText(t *testing.T) { + for _, value := range []string{"42", "", "é 猫"} { + snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: value, ApplicationProcess: "soffice.bin", + LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: "B3"}} + want := value + ", B3" + if value == "" { + want = "blank, B3" + } + if got := FocusText(snapshot); got != want { + t.Fatalf("cell %q = %q, want %q", value, got, want) + } + reading := config.DefaultSettings().Reading + reading.OnlySpeakDisplayedText = true + options := DefaultOptions() + options.Reading = &reading + if got := FocusTextWithOptions(snapshot, options); got != value { + t.Fatalf("displayed cell %q = %q", value, got) + } + if snapshot.Name != "B3" || snapshot.Text != value { + t.Fatal("presentation mutated native semantics") + } + } +} + +func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) { + for _, process := range []string{"", "chromium", "steamwebhelper"} { + snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: "42", ApplicationProcess: process, + LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: "wrong", Coordinates: "wrong"}} + if got := FocusText(snapshot); got != "B3, 42" { + t.Fatalf("other process %q got LibreOffice policy: %q", process, got) + } + } +} diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index 4489cf7..347a708 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -77,6 +77,7 @@ func FocusTextWithOptions(snapshot atspi.Snapshot, options Options) string { } func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech { + snapshot = libreOfficeFocusSnapshot(snapshot, options) // GTK sliders can expose the previous displayed number through Text // while Value already contains the current number. Present Value once. if snapshot.Role == "slider" && controlValue(snapshot) != "" { @@ -214,7 +215,8 @@ func EventSpeechWithOptions(event atspi.Event, options Options) Speech { focus := speech.Text if event.Source.Role == "table cell" && event.Source.TableName != "" { table := atspi.Snapshot{Role: "table", TableRowCount: event.Source.TableRowCount, - TableColumnCount: event.Source.TableColumnCount} + TableColumnCount: event.Source.TableColumnCount, ApplicationProcess: event.Source.ApplicationProcess, + LibreOffice: event.Source.LibreOffice} speech = speech.Prepend(FocusTextWithOptions(table, options), ", ") speech = speech.Prepend(event.Source.TableName, ", ") focus = speech.Text diff --git a/internal/wine/manager.go b/internal/wine/manager.go index 52499ad..4bf930f 100644 --- a/internal/wine/manager.go +++ b/internal/wine/manager.go @@ -1,4 +1,4 @@ -// Package wine supervises the optional installed Cthulhu Wine accessibility +// Package wine supervises the optional installed Wine Access accessibility // helper. Native Wine objects stay in that process; Go receives D-Bus messages. package wine @@ -47,17 +47,28 @@ func New(logger *slog.Logger) *Manager { } func helperPath() string { + return findHelper([]string{ + filepath.Join(os.Getenv("HOME"), ".local", "libexec", "wine-access", "wine-access.exe"), + filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"), + "/usr/local/libexec/wine-access/wine-access.exe", + "/usr/libexec/wine-access/wine-access.exe", + "/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe", + }) +} + +func findHelper(candidates []string) string { if path := os.Getenv("CTHULHU_WINE_ACCESS_HELPER"); executable(path) { return path } - if path := filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"); executable(path) { - return path + for _, path := range candidates { + if executable(path) { + return path + } } - if path := "/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"; executable(path) { - return path - } - if path, err := exec.LookPath("cthulhu-wine-access.exe"); err == nil { - return path + for _, name := range []string{"wine-access.exe", "cthulhu-wine-access.exe"} { + if path, err := exec.LookPath(name); err == nil { + return path + } } return "" } diff --git a/internal/wine/manager_test.go b/internal/wine/manager_test.go index d5681e9..06049b2 100644 --- a/internal/wine/manager_test.go +++ b/internal/wine/manager_test.go @@ -119,7 +119,18 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) { t.Setenv("HOME", root) t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "") - local := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe") + legacy := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe") + if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacy, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatal(err) + } + if got := helperPath(); got != legacy { + t.Fatalf("helperPath() = %q, want existing user helper %q", got, legacy) + } + + local := filepath.Join(root, ".local", "libexec", "wine-access", "wine-access.exe") if err := os.MkdirAll(filepath.Dir(local), 0700); err != nil { t.Fatal(err) } @@ -140,6 +151,50 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) { } } +func TestFindHelperPrecedenceAndExecutableFiltering(t *testing.T) { + root := t.TempDir() + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "") + t.Setenv("PATH", root) + candidates := []string{ + filepath.Join(root, "user", "wine-access.exe"), + filepath.Join(root, "usr-local", "wine-access.exe"), + filepath.Join(root, "usr", "wine-access.exe"), + } + for _, path := range candidates { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + } + if got := findHelper(candidates); got != "" { + t.Fatalf("missing helpers = %q, want empty", got) + } + legacy := filepath.Join(root, "cthulhu-wine-access.exe") + pathHelper := filepath.Join(root, "wine-access.exe") + for _, path := range []string{legacy, pathHelper, candidates[2], candidates[1], candidates[0]} { + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatal(err) + } + if got := findHelper(candidates); got != path { + t.Fatalf("findHelper() = %q, want %q", got, path) + } + } + if err := os.Chmod(candidates[0], 0600); err != nil { + t.Fatal(err) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", candidates[0]) + if got := findHelper(candidates); got != candidates[1] { + t.Fatalf("non-executable override/user helper = %q, want %q", got, candidates[1]) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", root) + if got := findHelper(candidates); got != candidates[1] { + t.Fatalf("directory override = %q, want %q", got, candidates[1]) + } + t.Setenv("CTHULHU_WINE_ACCESS_HELPER", pathHelper) + if got := findHelper(candidates); got != pathHelper { + t.Fatalf("executable override = %q, want %q", got, pathHelper) + } +} + func TestManagerLaunchesAndStopsHelperForActivePrefix(t *testing.T) { root := t.TempDir() procRoot := filepath.Join(root, "proc") diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 932a0ab..e39bc1a 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -54,3 +54,4 @@ web-browse-selection,src/cthulhu/scripts/web/script.py;src/cthulhu/script_utilit web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Fixture speech verified; real Steam listening pending,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging +libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. diff --git a/tests/libreoffice.fods b/tests/libreoffice.fods new file mode 100644 index 0000000..31c1afa --- /dev/null +++ b/tests/libreoffice.fods @@ -0,0 +1,27 @@ + + + + + + + Alpha + 42 + + + + Beta + + + + + Gamma + + + + + diff --git a/tests/native_libreoffice.py b/tests/native_libreoffice.py new file mode 100644 index 0000000..9bf4f79 --- /dev/null +++ b/tests/native_libreoffice.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Real Calc through the production reader, in a private X11/AT-SPI session.""" + +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time + + +def wait_for(check, message, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = check() + if result: + return result + time.sleep(0.05) + raise RuntimeError(message) + + +def run_inside(): + import gi + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-") as temporary: + root = Path(temporary) + profile = root / "profile" / "user" + profile.mkdir(parents=True) + (profile / "registrymodifications.xcu").write_text( + '' + '' + 'false' + 'false' + '', encoding="utf-8", + ) + document = root / "Example.fods" + shutil.copyfile(Path(__file__).with_name("libreoffice.fods"), document) + environment = dict(os.environ, SAL_USE_VCLPLUGIN="gtk3", + LC_ALL="C.UTF-8", + GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0", + XDG_CONFIG_HOME=str(root / "config"), + XDG_CACHE_HOME=str(root / "cache"), + XDG_DATA_HOME=str(root / "data")) + application = subprocess.Popen( + ["libreoffice", "-env:UserInstallation=" + (root / "profile").as_uri(), + "--norestore", "--nodefault", "--nofirststartwizard", "--calc", str(document)], + env=environment, start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + def find_window(): + result = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc"], + capture_output=True, text=True, check=False, + ) + return result.stdout.splitlines()[-1] if result.stdout.strip() else None + + window = wait_for(find_window, "Calc window missing") + # Wait for the actual accessible sheet, without enumerating its + # millions of cells. Dismiss disposable first-run UI if present. + def ready(): + pending = [Atspi.get_desktop(0)] + remaining = 2500 + found = False + while pending and remaining: + node = pending.pop() + remaining -= 1 + role = node.get_role_name() + if role == "table": + parent = node.get_parent() + found |= parent is not None and parent.get_role_name() == "document spreadsheet" + continue + if role == "dialog" and node.get_name() == "Welcome to LibreOffice!": + subprocess.run(["xdotool", "key", "Escape"], check=True) + return False + if role == "table cell": + continue + for index in range(min(node.get_child_count(), 200)): + child = node.get_child_at_index(index) + if child is not None: + pending.append(child) + return found + + wait_for(ready, "Calc accessible sheet missing") + environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window) + environment.setdefault("GOCACHE", "/tmp/cthulhu-go-cache") + subprocess.run( + ["go", "test", "./internal/app", "-run", "^TestNativeLibreOfficeCalc$", + "-count=1", "-timeout=50s", "-v"], + cwd=Path(__file__).resolve().parents[1], env=environment, + check=True, timeout=60, + ) + finally: + if application.poll() is None: + os.killpg(application.pid, signal.SIGTERM) + application.wait(timeout=5) + + +def main(): + if sys.argv[1:] == ["--inside"]: + run_inside() + return + with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-session-") as temporary: + environment = dict(os.environ, XDG_SESSION_TYPE="x11", + XDG_CONFIG_HOME=temporary, XDG_CACHE_HOME=temporary, + XDG_DATA_HOME=temporary) + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(name, None) + subprocess.run( + ["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, + str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=90, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/window_title_fixture.py b/tests/window_title_fixture.py index d60a4a3..e8c333d 100644 --- a/tests/window_title_fixture.py +++ b/tests/window_title_fixture.py @@ -25,7 +25,8 @@ def main(): normal = make_window(root, "Example title") desktop = make_window(root, "Wine Desktop") - helper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe") + helper = make_window(desktop, "NVDA", "wine-access.exe") + oldHelper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe") ime = make_window(desktop, "Default IME") hidden = make_window(desktop, "Hidden game", "hidden.exe") hidden.unmap() @@ -33,7 +34,7 @@ def main(): control = make_window(game, "") second = make_window(desktop, "Wine dialog", "game.exe") unrelated = make_window(root, "Unrelated title") - windows = dict(normal=normal, desktop=desktop, helper=helper, ime=ime, + windows = dict(normal=normal, desktop=desktop, helper=helper, oldHelper=oldHelper, ime=ime, hidden=hidden, game=game, control=control, second=second, unrelated=unrelated) root.change_property(activeAtom, Xatom.WINDOW, 32, [normal.id]) diff --git a/tests/wine_review_x11.py b/tests/wine_review_x11.py index b3f8ad8..6ada957 100644 --- a/tests/wine_review_x11.py +++ b/tests/wine_review_x11.py @@ -84,7 +84,7 @@ def main(): wait_for(focus,'Linux fixture missing',5) try: subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45,stdout=subprocess.DEVNULL) - processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)) + processes.append(subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)) processes.append(subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL)) wait_for(activate,'native Wine window missing',10) window = activate() @@ -103,14 +103,14 @@ def main(): subprocess.run(['wine',str(fixture),command.get('check','--check-strict')],env=env,check=True,timeout=5,stdout=subprocess.DEVNULL) elif action == 'restart-helper': processes[0].terminate();processes[0].wait(timeout=3) - processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) + processes[0]=subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) elif action == 'linux':activate_linux() elif action == 'wine':activate() elif action == 'second-prefix': known = set(subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True).stdout.splitlines()) secondary_env = dict(env, WINEPREFIX=str(Path(directory)/'second-prefix')) subprocess.run(['wineboot','-u'],env=secondary_env,check=True,timeout=45,stdout=subprocess.DEVNULL) - processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=secondary_env,stdout=subprocess.DEVNULL)) + processes.append(subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=secondary_env,stdout=subprocess.DEVNULL)) processes.append(subprocess.Popen(['wine',str(fixture)],env=secondary_env,stdout=subprocess.DEVNULL)) def focus_second(): found = subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True) @@ -127,7 +127,7 @@ def main(): subprocess.run(['wine','reg','add',r'HKCU\Software\Wine\Explorer\Desktops','/v','Default','/d','900x700','/f'],env=env,check=True,stdout=subprocess.DEVNULL,timeout=5) subprocess.run(['wineserver','-k'],env=env,check=True,timeout=5) processes[0].wait(timeout=3);processes[1].wait(timeout=3) - processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) + processes[0]=subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL) processes[1]=subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL) window = wait_for(activate,'virtual-desktop app missing',10) result = subprocess.run(['xdotool','search','--onlyvisible','--name','Wine Desktop'],capture_output=True,text=True,check=True)