From 7ec6e4ccaf6b31c861374ed0497185a2f2926005 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 01:54:25 -0500 Subject: [PATCH 1/4] Preserve native offsets after braille text expansion Expanding embedded-object placeholders changes the displayed braille text without changing native AT-SPI offsets. Several consumers still validated native positions against the expanded string, which could reject a valid line end after contraction, accept an offset beyond the native line after expansion, or drop selection and text-attribute indicators on a plain-text fragment following an embedded object. Use RawText for native-offset validation and fragment comparisons whenever a braille line is expanded. Apply the same rule to parent/child native-caret handoff: identical displayed text must not authorize an offset transfer when the underlying native strings differ. Expanded spans retain their existing conservative presentation without native cell routes. Restrict the assembled-line displayed-text fallback to lines from which embedded-object placeholders were actually removed. Ordinary whitespace-only lines now retain their spaces, cursor position, and native routing instead of being replaced with the object's text or accessible name. Add regressions for contracted and expanded native offset bounds, suffix selection and attribute preservation, immutable line enrichment, ambiguous caret transfer, and whitespace-only line routing. The regressions reproduced the review findings before the fixes and pass afterward. Validation: - Full Go suite before fixes; full race and BrlAPI suites after fixes. - Focused Braille and native-caret regressions, including race checks. - go vet ./...; reader and BrlAPI helper builds; parity-ledger validation. - Private Chromium linked-heading, selection, and browse-mode checks. - Private Firefox baseline and linked-heading checks. - git diff --check. The Chromium fragment harness still fails its embedded-editor navigation step, and the Firefox harness still times out in its New Tab test. Both failures reproduce on the parent of the reviewed commit, 8ce5e5c, as well. These failures are not claimed as passing checks. The active reader was not replaced. Physical keyboard, audible speech, and Braille acceptance remain for maintainer testing: check linked headings, mixed inline-link text, selection indicators after links, and ordinary or blank-line cursor routing in both browser families before submission. --- internal/app/braille_presentation.go | 4 +- internal/app/braille_presentation_test.go | 18 ++++++++ internal/review/document_braille.go | 12 +++++- internal/review/document_braille_test.go | 43 +++++++++++++++++++ internal/review/document_native_caret.go | 3 +- internal/review/document_native_caret_test.go | 17 ++++++++ internal/version/version.go | 2 +- 7 files changed, 94 insertions(+), 5 deletions(-) diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index 49e8b5b..7a81a4d 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -79,6 +79,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c } } if assembled != nil { + strippedObjects := false for index, content := range assembled.Contents { raw := content.RawText if index == len(assembled.Contents)-1 { @@ -91,11 +92,12 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c // as their own contents; drop it instead of duplicating them. native := !strings.ContainsRune(raw, '\ufffc') if !native { + strippedObjects = true raw = stripEmbeddedObjects(raw) } appendContent(content.Snapshot, raw, content.Separator, content.Start, native) } - if strings.TrimSpace(frame.Text) == "" { + if strippedObjects && strings.TrimSpace(frame.Text) == "" { // Nothing but placeholders survived assembly; fall back to // the displayed text speech would use for this object. if text := brailleDisplayedText(snapshot); text != "" { diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index e86473a..0e379c9 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -481,6 +481,24 @@ func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T) } } +func TestBrowseBrailleAssembledWhitespaceKeepsNativeMapping(t *testing.T) { + snapshot := atspi.Snapshot{BusName: ":1.999", Path: "/text", Role: "static", Name: "Accessible label", Text: "Other text"} + tree, err := atspi.NewAccessibleTree(snapshot.ID(), []atspi.TreeNode{{Snapshot: snapshot}}) + if err != nil { + t.Fatal(err) + } + object, _ := tree.Reference(snapshot.ID()) + result := review.Result{DocumentEpoch: 1, DocumentFocus: snapshot.ID(), + DocumentPosition: review.DocumentPosition{Document: object, Snapshot: snapshot, Offset: 1}, + DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{{ + Snapshot: snapshot, Start: 0, End: 2, RawText: " ", + }}}} + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if frame.Text != " " || frame.Cursor != 1 || len(frame.Routes) != 1 || frame.Routes[0].SourceText != " " { + t.Fatalf("ordinary whitespace replaced by object fallback: %+v", frame) + } +} + func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { return atspi.DocumentCaretText{}, nil } diff --git a/internal/review/document_braille.go b/internal/review/document_braille.go index b67220c..979bc9e 100644 --- a/internal/review/document_braille.go +++ b/internal/review/document_braille.go @@ -40,7 +40,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re return result } if line.Start < 0 || result.DocumentPosition.Offset < line.Start || - result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(line.Text) || line.Text == "" { + result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(documentBrailleNativeText(line)) || line.Text == "" { result.DocumentBrailleErr = atspi.ErrNoDocumentCaret return result } @@ -62,7 +62,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re } } start, end := content.Start-selected.Start, content.Start-selected.Start+utf8.RuneCountInString(content.RawText) - runes := []rune(selected.Text) + runes := []rune(documentBrailleNativeText(selected)) if start < 0 || end > len(runes) || string(runes[start:end]) != content.RawText { continue } @@ -73,3 +73,11 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re } return result } + +// Braille expansion changes display characters, never the native offset domain. +func documentBrailleNativeText(line atspi.DocumentCaretText) string { + if line.Expanded { + return line.RawText + } + return line.Text +} diff --git a/internal/review/document_braille_test.go b/internal/review/document_braille_test.go index 3d9b0ca..0f0e782 100644 --- a/internal/review/document_braille_test.go +++ b/internal/review/document_braille_test.go @@ -7,6 +7,7 @@ package review import ( "context" "errors" + "reflect" "testing" "time" @@ -79,3 +80,45 @@ func TestDocumentBrailleReadsOnlyEnabledAcceptedNavigation(t *testing.T) { t.Fatalf("braille read failure changed successful navigation: %+v", got) } } + +func TestDocumentBrailleExpandedLineUsesNativeOffsets(t *testing.T) { + reader := &documentBrailleReader{fakeCapturer: &fakeCapturer{}, line: atspi.DocumentCaretText{ + Start: 0, End: 5, RawText: "a\ufffc bc", Text: "a bc", Expanded: true, + }} + worker, err := NewWorker(reader, time.Second) + if err != nil { + t.Fatal(err) + } + cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): reader.line.RawText}) + object, _ := cursor.tree.Reference(webID("/text")) + worker.SyncFocus(object.ID()) + worker.SetDocumentBrailleEnabled(true) + result := Result{Moved: true, DocumentFocus: object.ID(), + DocumentPosition: DocumentPosition{Object: object, Offset: 5}} + got := worker.readDocumentBraille(context.Background(), result) + if got.DocumentBrailleLine == nil || got.DocumentBrailleErr != nil { + t.Fatalf("valid native line end rejected after contraction: %+v", got) + } + + reader.line.Text = "aLong link bc" + reader.line.SelectionStart, reader.line.SelectionEnd = 3, 5 + reader.line.TextAttributeRuns = []atspi.TextAttributeRun{{Start: 3, End: 5, Values: map[string]string{"weight": "700"}}} + result.DocumentPosition.Offset = 3 + result.DocumentCaretLine = &atspi.DocumentLine{Contents: []atspi.DocumentContent{{ + Reference: object, Snapshot: atspi.Snapshot{BusName: object.ID().BusName, Path: object.ID().Path}, + Start: 3, End: 5, RawText: "bc", + }}} + got = worker.readDocumentBraille(context.Background(), result) + snapshot := got.DocumentCaretLine.Contents[0].Snapshot + if snapshot.SelectionStart != 3 || snapshot.SelectionEnd != 5 || !reflect.DeepEqual(snapshot.TextAttributeRuns, reader.line.TextAttributeRuns) { + t.Fatalf("native suffix indicators lost after display expansion: %+v", snapshot) + } + if result.DocumentCaretLine.Contents[0].Snapshot.SelectionEnd != 0 { + t.Fatal("enrichment mutated the original line") + } + result.DocumentPosition.Offset = 6 + got = worker.readDocumentBraille(context.Background(), result) + if got.DocumentBrailleLine != nil || !errors.Is(got.DocumentBrailleErr, atspi.ErrNoDocumentCaret) { + t.Fatalf("display length accepted an invalid native offset: %+v", got) + } +} diff --git a/internal/review/document_native_caret.go b/internal/review/document_native_caret.go index e099d7e..71286e9 100644 --- a/internal/review/document_native_caret.go +++ b/internal/review/document_native_caret.go @@ -79,7 +79,8 @@ func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atsp if err != nil { return atspi.DocumentNode{}, 0, err } - if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text { + parentNative, childNative := documentBrailleNativeText(parentText), documentBrailleNativeText(childText) + if parentText.Start != childText.Start || parentText.End != childText.End || parentNative == "" || parentNative != childNative { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } node = child diff --git a/internal/review/document_native_caret_test.go b/internal/review/document_native_caret_test.go index 66c8314..a1879e0 100644 --- a/internal/review/document_native_caret_test.go +++ b/internal/review/document_native_caret_test.go @@ -90,6 +90,23 @@ func TestNativeCaretRejectsAmbiguousOffsetDomains(t *testing.T) { } } +type expandedCaretReader map[atspi.ObjectID]atspi.DocumentCaretText + +func (reader expandedCaretReader) ReadDocumentCaret(_ context.Context, object atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + return reader[object.ID()], nil +} + +func TestNativeCaretRejectsMatchingExpandedTextWithDifferentNativeOffsets(t *testing.T) { + cursor := mirroredCaretCursor(t, caretTextReader{webID("/paragraph"): "\ufffcxy", webID("/text"): "axy"}, false) + reader := expandedCaretReader{ + webID("/paragraph"): {Start: 0, End: 3, RawText: "\ufffcxy", Text: "axy", Expanded: true}, + webID("/text"): {Start: 0, End: 3, RawText: "axy", Text: "axy"}, + } + if err := cursor.syncNativeCaret(context.Background(), reader, webID("/paragraph"), 1); !errors.Is(err, atspi.ErrNoDocumentCaret) { + t.Fatalf("expanded display text authorized native offset transfer: %v", err) + } +} + func TestWorkerNativeCaretHandoffPrecedesNextArrow(t *testing.T) { text := caretTextReader{webID("/text"): "When autocomplete"} navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text} diff --git a/internal/version/version.go b/internal/version/version.go index f88c51c..9a21679 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.11.02" +const Version = "2026.11.03" const Channel = "hunter" var Build = Version From 89686fa0297c69ec077e398d9f9682b7130998b1 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 02:37:07 -0500 Subject: [PATCH 2/4] Refresh browser tests and recover Firefox tab announcements Bring the fragmented-line browser fixture into agreement with current browse navigation. Rich editors are read through their text descendants, so Down from the preceding heading must read "Review the code." directly. Remove the stale empty editor-control stop while retaining forward and reverse line navigation, character and word movement, Read All, and inline Braille-routing assertions. Fix Firefox's missing announcement when creating a second tab. Firefox can emit selection events before the new tab enters its accessible child list, causing the native selected-child lookup to return nothing. Use the subsequent child-added event only when its target is a live, freshly selected browser-chrome tab with the emitting tab list as its direct parent. Keep document-authored tabs and unselected background additions excluded, retain foreground filtering and tab-identity deduplication, and release native references on the GLib owner. Normalize child-added origin suffixes such as :system before event dispatch, preserving the original native type in diagnostics. Rename the shared mutation normalizer to reflect its expanded scope and update its callers, including the Pidgin insertion check. Extend the normalization regression to cover child additions and reject unrelated event names. Firefox keeps its normal animations and all existing tab-announcement and address-editing assertions. Make private-session teardown tolerate the document portal's asynchronous FUSE unmount after D-Bus exits. Retry only ENOTCONN and EBUSY cleanup failures for at most five seconds. Keep unrelated errors and persistent mount failures visible. Add regressions for transient teardown, preservation of a failing child's exit status, unrelated cleanup errors, and the cleanup deadline. Runtime directories and accessibility buses remain private, with no init-system dependency. Update browser verification notes and the parity ledger with the fixes, evidence, regression risks, and concrete live acceptance procedures. Validation: - All 33 Chromium browser proofs passed in the final combined run. - Firefox baseline and tab checks passed repeatedly with normal animations. - All eight private-session isolation and cleanup regressions passed. - AT-SPI, app, and review package race suites passed during development; focused race regressions passed again after the final event-normalization change. - Go vet passed for internal/atspi, internal/app, and internal/review. - A separate reader binary built successfully. - Parity-ledger validation passed for 75 rows; git diff --check passed. --- docs/testing/browser-audit-fixes.md | 54 +++++++++++++++++- .../atspi/adapter_event_callback_native.go | 15 +++-- internal/atspi/adapter_event_target_native.go | 53 +++++++++++++++++- internal/atspi/model.go | 8 +-- internal/atspi/model_test.go | 10 +++- internal/atspi/pidgin_native.go | 2 +- .../document_visual_line_native_test.go | 2 +- internal/version/version.go | 2 +- testdata/parity-ledger.csv | 2 +- tests/native_test_session.py | 20 ++++++- tests/test_native_test_session.py | 55 +++++++++++++++++++ 11 files changed, 199 insertions(+), 24 deletions(-) diff --git a/docs/testing/browser-audit-fixes.md b/docs/testing/browser-audit-fixes.md index a697a74..fea2920 100644 --- a/docs/testing/browser-audit-fixes.md +++ b/docs/testing/browser-audit-fixes.md @@ -58,11 +58,12 @@ The final `live-atomic` run also verifies that busy completion requires no new t insertion. It covers atomic text updates, atomic child additions, non-atomic updates, suppression while busy, and release after busy becomes false. -Firefox baseline reading passed. Its tab regression timed out on the second +At the original review, Firefox baseline reading passed. Its tab regression timed +out on the second “New Tab” announcement in both the modified checkout and an untouched `bf4b9d7` snapshot. The recorded events include a selected-state event for the previous -unselected tab. This is an unresolved existing/provider case, not a verified -regression introduced by these changes. +unselected tab. That failure predated the changes reviewed here. The browser-test +maintenance below addresses it without relaxing the announcement assertions. The affected `go vet` checks, normal reader build, BrlAPI helper build, Python syntax check, parity-ledger validation and whitespace check passed. No installed @@ -90,6 +91,53 @@ go vet ./internal/atspi ./internal/review ./internal/app go run ./cmd/parityledger validate testdata/parity-ledger.csv ``` +## Browser-test maintenance (2026-11-03) + +The fragmented-line fixture expected an empty editor-control stop before its +first text line. Current browse navigation and its unit regression deliberately +descend directly into rich-editor text. The native fixture now requires that same +sequence, retaining forward/backward reading, character/word movement, Read All +and inline Braille-routing checks. + +Firefox can emit tab selection before the new tab enters its accessible child +list. Its later child-added event exposes the selected tab. The native adapter +now recognizes that event only for a live, freshly selected browser-chrome tab +whose parent matches the emitting tab list. Document-authored tabs and unselected +background additions do not qualify. Existing foreground checks and tab-identity +deduplication still apply. Child-added origin suffixes such as `:system` are +normalized before dispatch, with the original native type retained in diagnostics. +The browser profile keeps its normal animations; the tab test still requires an +announcement for each creation, switch and close, without duplicate announcements +or automatic address-rewrite speech. + +Private-session cleanup now waits up to five seconds for a document portal's +FUSE mount to disappear after D-Bus exits. Only disconnected/busy cleanup errors +are retried; unrelated errors and a persistent mount still fail the run. The +isolation regressions also verify that retries preserve a failing child test's +exit status. + +All 33 Chromium proofs passed in the final combined run, and Firefox baseline +and tab checks passed in repeated runs with normal animations. Eight private- +session regressions, focused package race tests, affected-package vet checks and +a separate reader build passed. An earlier Chromium run failed generated-link +Read All with “accessible children require recapture”; its isolated and final +combined reruns passed. That intermittent failure's cause is not established. + +```sh +python3 -B tests/test_native_test_session.py +CTHULHU_NATIVE_BROWSER_TESTS=fragments,tabs,linkedheadings,selection,modes,live,live-atomic python3 tests/native_browser.py +python3 tests/native_firefox.py +``` + +For live acceptance, create two Firefox tabs in quick succession, switch between +them, then close them. Each action should announce the newly selected tab once; +editing and deleting text in the address bar must still speak. Open a link in a +background tab and check that its creation does not announce a tab switch. Check +page-authored ARIA tabs and an ordinary GTK notebook retain their own behavior. +In `tests/browser-fragments.html`, navigate to “Embedded suggestion”: Down should +read “Review the code.” immediately, followed by “Report actionable issues.” and +“After the suggestion.” Check reverse navigation, Read All and Braille routing. + ## Human acceptance before submission Review the generated diff under the project's AI-assisted contribution policy. diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 79eb7de..a8c3263 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -420,7 +420,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { } return } - eventType := canonicalTextChangeEventType(nativeEventType) + eventType := canonicalObjectChangeEventType(nativeEventType) originID := nativeObjectID(event.source) if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { C.cthulhu_forget_menu_checkability(event.source) @@ -449,9 +449,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.forgetWindowDocuments(originID) } invalidated := adapter.applyTreeInvalidation(eventType, event) - returnedMenuItem := adapter.returned_menu_item(event, eventType, originID) - if returnedMenuItem != nil { - defer C.g_object_unref(C.gpointer(returnedMenuItem)) + returnedTarget := adapter.returned_menu_item(event, eventType, originID) + if returnedTarget != nil { + defer C.g_object_unref(C.gpointer(returnedTarget)) // Keep the original showing event and popup origin in diagnostics. // Only fresh focus on its parent supplies this normalized focus event. eventType, invalidated = FocusEvent, false @@ -505,6 +505,11 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.backgroundEventsFiltered.Add(1) return } + if tab := addedSelectedBrowserTab(event, eventType); tab != nil { + defer C.g_object_unref(C.gpointer(tab)) + returnedTarget = tab + eventType, invalidated = SelectionChangedEvent, false + } var live nativeLiveProperties if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent || eventType == BusyStateEvent { live = capture_live_properties(event.source) @@ -523,7 +528,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { return } snapshotStarted := time.Now() - target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedMenuItem) + target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedTarget) if target == nil { return } diff --git a/internal/atspi/adapter_event_target_native.go b/internal/atspi/adapter_event_target_native.go index e1aefcb..a5a53f0 100644 --- a/internal/atspi/adapter_event_target_native.go +++ b/internal/atspi/adapter_event_target_native.go @@ -9,11 +9,58 @@ package atspi */ import "C" -// resolveNativeEventTarget runs on the GLib owner. The returned menu item is +// addedSelectedBrowserTab handles providers which emit selection before the new +// tab enters their accessible child list. The later child-added event supplies +// the actual target; only a freshly selected browser chrome tab qualifies. +// The caller releases the retained target on the GLib owner. +func addedSelectedBrowserTab(event *C.AtspiEvent, eventType string) *C.AtspiAccessible { + if eventType != ChildrenAddedEvent || C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_PAGE_TAB_LIST { + return nil + } + child := C.cthulhu_event_active_descendant(event) + if child == nil || C.atspi_accessible_get_role(child, nil) != C.ATSPI_ROLE_PAGE_TAB { + return nil + } + parent := C.atspi_accessible_get_parent(child, nil) + if parent == nil { + return nil + } + matchesParent := nativeObjectID(parent) == nativeObjectID(event.source) + C.g_object_unref(C.gpointer(parent)) + if !matchesParent { + return nil + } + C.atspi_accessible_clear_cache_single(child) + states := C.atspi_accessible_get_state_set(child) + if states == nil { + return nil + } + selected := C.atspi_state_set_contains(states, C.ATSPI_STATE_SELECTED) != 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 + C.g_object_unref(C.gpointer(states)) + if !selected { + return nil + } + toolkit := C.atspi_accessible_get_toolkit_name(child, nil) + snapshot := Snapshot{Role: "page tab", ToolkitName: C.GoString(toolkit)} + C.g_free(C.gpointer(toolkit)) + document := C.cthulhu_document_ancestor(child) + if document != nil { + C.g_object_unref(C.gpointer(document)) + return nil + } + if !isBrowserChromeTab(snapshot, false) { + return nil + } + C.g_object_ref(C.gpointer(child)) + return child +} + +// resolveNativeEventTarget runs on the GLib owner. The override target is // borrowed from the callback; LibreOffice and selected descendants can return // owned references. The caller releases an owned target after event capture. -func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, returnedMenuItem *C.AtspiAccessible) (*C.AtspiAccessible, bool) { - target := returnedMenuItem +func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, overrideTarget *C.AtspiAccessible) (*C.AtspiAccessible, bool) { + target := overrideTarget ownedTarget := false if target == nil { target = adapter.libreOfficeFocusTarget(event, eventType) diff --git a/internal/atspi/model.go b/internal/atspi/model.go index d031214..eafafea 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -349,18 +349,18 @@ func NewEvent(eventType string, source Snapshot) Event { if eventType == "object:state-changed:checked" && source.Role == "menu item" { source.Checkable = true } - event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path} + event := Event{Type: canonicalObjectChangeEventType(eventType), Source: source, OriginPath: source.Path} if event.Type != eventType { event.NativeType = eventType } return event } -// AT-SPI appends an origin to some text mutations (for example, :system). +// AT-SPI appends an origin to some object mutations (for example, :system). // The reader handles those mutations by kind; leaving the suffix on Type // makes them fall through as ordinary object presentation events. -func canonicalTextChangeEventType(eventType string) string { - for _, kind := range []string{TextInsertedEvent, TextDeletedEvent} { +func canonicalObjectChangeEventType(eventType string) string { + for _, kind := range []string{TextInsertedEvent, TextDeletedEvent, ChildrenAddedEvent} { if eventType == kind || strings.HasPrefix(eventType, kind+":") { return kind } diff --git a/internal/atspi/model_test.go b/internal/atspi/model_test.go index e1cbdd3..4928270 100644 --- a/internal/atspi/model_test.go +++ b/internal/atspi/model_test.go @@ -45,17 +45,21 @@ func TestShouldSpeakEvent(t *testing.T) { } } -func TestTextChangeOriginIsCanonicalized(t *testing.T) { +func TestObjectChangeOriginIsCanonicalized(t *testing.T) { for _, test := range []struct{ incoming, want string }{ {TextInsertedEvent + ":system", TextInsertedEvent}, {TextDeletedEvent + ":system", TextDeletedEvent}, {TextInsertedEvent, TextInsertedEvent}, {TextDeletedEvent, TextDeletedEvent}, + {ChildrenAddedEvent + ":system", ChildrenAddedEvent}, + {ChildrenAddedEvent, ChildrenAddedEvent}, + {ChildrenRemovedEvent + ":system", ChildrenRemovedEvent + ":system"}, + {"object:children-changed:additional", "object:children-changed:additional"}, {"object:text-caret-moved", "object:text-caret-moved"}, {"object:text-changed:insertion", "object:text-changed:insertion"}, } { - if got := canonicalTextChangeEventType(test.incoming); got != test.want { - t.Errorf("canonicalTextChangeEventType(%q) = %q, want %q", test.incoming, got, test.want) + if got := canonicalObjectChangeEventType(test.incoming); got != test.want { + t.Errorf("canonicalObjectChangeEventType(%q) = %q, want %q", test.incoming, got, test.want) } if got := NewEvent(test.incoming, Snapshot{}).Type; got != test.want { t.Errorf("NewEvent(%q).Type = %q, want %q", test.incoming, got, test.want) diff --git a/internal/atspi/pidgin_native.go b/internal/atspi/pidgin_native.go index 89d47c0..4aab4b3 100644 --- a/internal/atspi/pidgin_native.go +++ b/internal/atspi/pidgin_native.go @@ -35,7 +35,7 @@ import ( // title or application-supplied name. Only verified Pidgin transcript insertions // bypass background filtering. Entry typing and buddy-list changes do not. func (adapter *Adapter) pidginMessage(event *C.AtspiEvent) *messaging.Message { - if canonicalTextChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" || + if canonicalObjectChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" || C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_TEXT { return nil } diff --git a/internal/review/document_visual_line_native_test.go b/internal/review/document_visual_line_native_test.go index f1b1502..d9df652 100644 --- a/internal/review/document_visual_line_native_test.go +++ b/internal/review/document_visual_line_native_test.go @@ -196,7 +196,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) { command DocumentCaretCommand want string }{ - {CaretNextLine, ""}, // Stop on the editor control before entering its text. + // Read rich-editor descendants directly, without an empty control stop. {CaretNextLine, "Review the code."}, {CaretNextLine, "Report actionable issues."}, {CaretNextLine, "After the suggestion."}, diff --git a/internal/version/version.go b/internal/version/version.go index 9a21679..24ae986 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -22,7 +22,7 @@ import "strings" const Name = "Cthulhu" const Version = "2026.11.03" -const Channel = "hunter" +const Channel = "testing" var Build = Version diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 9137460..bd3885f 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -62,7 +62,7 @@ steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test 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. Maintainer confirms basic cell navigation and editing work in live use on 2026-10-22. Selection and sheet-switching acceptance await real-world testing. See docs/testing/release-acceptance.md.,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. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic row and column headers now use session choices scoped by provider bus document view window and exact sheet name; native reads are bounded to two cells. Four assignable commands preserve existing shortcuts. Private i3 Calc checks cover heading edits independent axis clearing sheet round trips both workbook-return directions with distinct headings and Find and Replace typing/cancellation. Frame activation and X11 foreground recovery avoid stale descendant searches; physical speech/input/Braille acceptance remains pending. Full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md. steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md. wayland-shared-keyboard-access,src/cthulhu/input_event_manager.py,input,Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface,Storm Dragon,internal/platform/wayland internal/app internal/sessionbus,in-progress,Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py,Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established.,Shared D-Bus fixture passed without an X display. On 2026-10-22 the maintainer reports minimal live testing and user reports of poorer behavior than native Xorg. Specific compositor and physical key-suppression acceptance remain unverified. See docs/testing/release-acceptance.md.,Minimal live Wayland testing reported by the maintainer on 2026-10-22; broad listening acceptance is not established.,Pending physical Braille on Wayland,No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency. -web-browse-recovery-activation,Orca src/orca/scripts/web/script_utilities.py; master:src/cthulhu/scripts/web/script.py,web,Recover dynamic browse positions and activate custom clickables silently,,internal/atspi internal/review internal/app,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; python3 tests/native_browser_navigation.py; python3 tests/native_firefox.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Chromium and Firefox fixtures; real-reader changing-page and activation checks use private Xvfb and D-Bus. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,Selection-collapse regression and native mode frames covered; physical routing remains pending.,See docs/testing/browser-release-review.md for findings and live acceptance. No full Orca parity claim. October 22 generated-link recovery retains the same parent and adjacent sibling identities; refreshes navigation-defunct child order and disappearing native text; and gives Read All navigation recovery with document-generation rejection. The page-CSS fixture reproduces the first fix failure and passes after correction. Full-article reading on the original Brave page is accepted; separate manual reverse-navigation Read All activation and physical Braille checks were not reported. The separate dynamic-page flat-review failure remains open. +web-browse-recovery-activation,Orca src/orca/scripts/web/script_utilities.py; master:src/cthulhu/scripts/web/script.py,web,Recover dynamic browse positions and activate custom clickables silently,,internal/atspi internal/review internal/app,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; python3 tests/native_browser_navigation.py; python3 tests/native_firefox.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Chromium and Firefox fixtures; real-reader changing-page and activation checks use private Xvfb and D-Bus. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,Selection-collapse regression and native mode frames covered; physical routing remains pending.,See docs/testing/browser-release-review.md for findings and live acceptance. No full Orca parity claim. October 22 generated-link recovery retains the same parent and adjacent sibling identities; refreshes navigation-defunct child order and disappearing native text; and gives Read All navigation recovery with document-generation rejection. The page-CSS fixture reproduces the first fix failure and passes after correction. Full-article reading on the original Brave page is accepted; separate manual reverse-navigation Read All activation and physical Braille checks were not reported. The separate dynamic-page flat-review failure remains open. November 3 browser-test maintenance corrects the stale empty editor-stop expectation and private portal teardown. Firefox tab creation now handles a selected browser-chrome child added after early selection events; native origin suffixes are preserved in diagnostics. All 33 Chromium proofs and repeated Firefox baseline/tab runs pass; live rapid-tab and background-tab acceptance remains pending. See docs/testing/browser-audit-fixes.md#browser-test-maintenance-2026-11-03. An earlier generated-link Read All recapture failure did not reproduce in isolated or final combined reruns; its cause remains unverified. web-structural-lists,Orca src/orca/structural_navigator.py; src/orca/structural_navigator_command_definitions.py,web,List browser elements and move to a selected item,,internal/input internal/review internal/interaction internal/app,implemented,"go test -race ./... -count=1; CTHULHU_NATIVE_BROWSER_TESTS=lists,keys,document,modes,editing python3 tests/native_browser.py; python3 tests/native_internal_ui.py",Private Brave/Xvfb list workflow covers 302 links hidden content current row selection Escape clickables heading level and empty list.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,Shared internal UI frames covered; physical Braille acceptance pending.,25 browse shortcuts use Alt+Shift. All Orca list types are registered including unbound annotations internal frames and math. Exact targets and page ownership are validated before selection. See docs/testing/browser-lists.md for manual review and regression checks. mozilla-leaf-text-reading,Orca src/orca/scripts/web/script_utilities.py,web,Read native heading and link text leaves in Firefox and Thunderbird,,internal/atspi tests/native_mozilla.py,implemented,python3 tests/native_firefox.py; CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_mozilla.py; go test -race ./internal/atspi ./internal/review ./internal/presentation ./internal/app -count=1 -timeout=120s,Private Firefox 158.0 and Thunderbird 158.0 sessions each cover 17 navigation commands and Read All with zero native caret synchronization errors.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,Shared Chromium selection and routing checks passed; physical Mozilla Braille acceptance pending.,Shared document text metrics now include heading and link roles. Native Firefox metadata assertion failed before the fix. See docs/testing/mozilla-reading.md. Thunderbird folders message lists and composition remain outside this reading baseline. thunderbird-local-mail-composition,Orca src/orca/scripts/apps/Thunderbird/script.py;src/orca/script_utilities.py,application,Navigate local folders and message tabs; select messages; compose replies and forwards; save and add attachments,,internal/atspi internal/app tests/native_thunderbird.py,implemented,go test -race ./internal/atspi ./internal/app ./internal/review ./internal/presentation -count=1 -timeout=120s; CTHULHU_BINARY=/tmp/cthulhu-mozilla-review /usr/bin/python3 tests/native_thunderbird.py,Private Thunderbird 158.0 HTML and plain-text local mailbox checks cover message-tab return; Subject and body editing; reply and reply-all recipients with Cc; inline forwarding; exact selected rows after Shift and Ctrl navigation; received attachment saving with content verification; and adding a local file to an unsent draft.,,SSIP verifies focus handoff and row roles; selection toggling; Subject and body echo; reply and forward editing; attachment menus and picker return. Maintainer relays third-party confirmation on 2026-10-22 that message reading; replies; forwards; and attachments work well in live testing. See docs/testing/release-acceptance.md.,Event-loop regression checks paragraph cursor and routing metadata; physical display acceptance pending.,Foreground replacement requires a registered application and fresh complete window membership. Editable paragraph events belong only to their focused editable document. Title echoes require owned editable focus and recent editing plus unchanged title prefix/suffix; unrelated status titles still announce. See docs/testing/thunderbird-workflows.md; no sending remote mailbox or page-load-option acceptance claimed. diff --git a/tests/native_test_session.py b/tests/native_test_session.py index 9999044..a79a4e1 100644 --- a/tests/native_test_session.py +++ b/tests/native_test_session.py @@ -3,10 +3,12 @@ # Authors: Storm Dragon, Angus Kola # SPDX-License-Identifier: GPL-3.0-or-later """Launch native tests with disposable runtime sockets and private D-Bus.""" +import errno import os import subprocess import sys import tempfile +import time def run_private_session(command, *, env, **kwargs): @@ -20,12 +22,26 @@ def run_private_session(command, *, env, **kwargs): # Keep this path short: AT-SPI falls back from named sockets if its # generated socket path is 100 bytes or longer. - with tempfile.TemporaryDirectory(prefix="cth-run-", dir="/tmp") as runtimeDir: - sessionEnv = dict(env, XDG_RUNTIME_DIR=runtimeDir) + runtime = tempfile.TemporaryDirectory(prefix="cth-run-", dir="/tmp") + try: + sessionEnv = dict(env, XDG_RUNTIME_DIR=runtime.name) for name in ("DBUS_SESSION_BUS_ADDRESS", "DBUS_STARTER_ADDRESS", "DBUS_STARTER_BUS_TYPE", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): sessionEnv.pop(name, None) return subprocess.run(command, env=sessionEnv, **kwargs) + finally: + # Activated document portals tear down their FUSE mount after D-Bus + # exits. Wait for that teardown instead of hiding a test's result with + # a transient disconnected-mount error, or ignoring cleanup failures. + deadline = time.monotonic() + 5 + while True: + try: + runtime.cleanup() + break + except OSError as error: + if error.errno not in (errno.ENOTCONN, errno.EBUSY) or time.monotonic() >= deadline: + raise + time.sleep(0.05) def main(): diff --git a/tests/test_native_test_session.py b/tests/test_native_test_session.py index 931a786..b2cbdc1 100644 --- a/tests/test_native_test_session.py +++ b/tests/test_native_test_session.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later """Runtime isolation and real AT-SPI socket collision regressions.""" import ast +import errno import json import os from pathlib import Path @@ -55,6 +56,60 @@ print(json.dumps(dict(runtime=str(runtime), mode=stat.S_IMODE(runtime.stat().st_ self.assertEqual(caught.exception.returncode, 7) self.assertFalse(Path(caught.exception.stdout.strip()).exists()) + def test_portal_teardown_retry_preserves_child_failure(self): + originalCleanup = tempfile.TemporaryDirectory.cleanup + attempts = [] + + def cleanup(runtime): + attempts.append(runtime.name) + if len(attempts) <= 2: + errorCode = (errno.ENOTCONN, errno.EBUSY)[len(attempts) - 1] + raise OSError(errorCode, "Document portal is shutting down") + originalCleanup(runtime) + + with patch("native_test_session.tempfile.TemporaryDirectory.cleanup", cleanup), \ + patch("native_test_session.time.sleep") as pause: + with self.assertRaises(subprocess.CalledProcessError) as caught: + run_private_session( + ["dbus-run-session", "--", sys.executable, "-c", "raise SystemExit(7)"], + env=os.environ, capture_output=True, check=True, timeout=15) + self.assertEqual(caught.exception.returncode, 7) + self.assertEqual(len(attempts), 3) + self.assertEqual(pause.call_count, 2) + self.assertFalse(Path(attempts[0]).exists()) + + def test_cleanup_does_not_hide_unrelated_errors(self): + originalCleanup = tempfile.TemporaryDirectory.cleanup + + def cleanup(runtime): + originalCleanup(runtime) + raise PermissionError(errno.EACCES, "Cleanup denied") + + with patch("native_test_session.tempfile.TemporaryDirectory.cleanup", cleanup), \ + patch("native_test_session.time.sleep") as pause: + with self.assertRaises(PermissionError): + run_private_session( + ["dbus-run-session", "--", sys.executable, "-c", "pass"], + env=os.environ, capture_output=True, check=True, timeout=15) + pause.assert_not_called() + + def test_portal_teardown_wait_is_bounded(self): + originalCleanup = tempfile.TemporaryDirectory.cleanup + + def cleanup(runtime): + originalCleanup(runtime) + raise OSError(errno.ENOTCONN, "Document portal did not unmount") + + with patch("native_test_session.tempfile.TemporaryDirectory.cleanup", cleanup), \ + patch("native_test_session.time.monotonic", side_effect=[0, 6]), \ + patch("native_test_session.time.sleep") as pause: + with self.assertRaises(OSError) as caught: + run_private_session( + ["dbus-run-session", "--", sys.executable, "-c", "pass"], + env=os.environ, capture_output=True, check=True, timeout=15) + self.assertEqual(caught.exception.errno, errno.ENOTCONN) + pause.assert_not_called() + def test_rejects_dbus_before_xvfb_without_launching(self): with patch("native_test_session.subprocess.run") as launch: with self.assertRaisesRegex(ValueError, "Start Xvfb before D-Bus"): From 14743f96bb0a2955e4cd9bc55c28934c66582dfd Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 03:02:00 -0500 Subject: [PATCH 3/4] Updated cthulhu build script. Should work on systems even with old versions of go. --- build-cthulhu.sh | 133 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 5 deletions(-) diff --git a/build-cthulhu.sh b/build-cthulhu.sh index e889b8c..e18e015 100755 --- a/build-cthulhu.sh +++ b/build-cthulhu.sh @@ -1,9 +1,132 @@ #!/usr/bin/env bash - +# Build both Cthulhu binaries without replacing the system Go installation. set -euo pipefail +projectDir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -go build -o cthulhu ./cmd/cthulhu -go build -tags brlapi -o cthulhu-braille ./cmd/cthulhu-braille -echo "Cthulhu has been built." -exit 0 +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: build-cthulhu.sh [-o DIR | --output-dir DIR] [-- GO_BUILD_FLAGS...] + +Build cthulhu and cthulhu-braille, obtaining a suitable Go toolchain if needed. + + -o, --output-dir DIR Write both binaries to DIR (default: checkout root). + Relative paths start at the caller's directory. + Missing directories are created. + -h, --help Show help without checking build dependencies. + -- Pass remaining flags to both go build commands. + +GOTOOLCHAIN defaults to auto. Set GOTOOLCHAIN=local to require installed Go +and prevent toolchain downloads. Other Go settings, compiler/linker flags, +and cache paths are inherited. CGO must be enabled (the default here). +If Go is absent or older than 1.21, the bootstrap toolchain is cached under +${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains; curl, tar and sha256sum +are needed only for this download. Installation belongs in the package recipe. +EOF +} + +outputDir=$projectDir +buildFlags=() +while (( $# )); do + case $1 in + -o|--output-dir) + (( $# >= 2 )) || fail "$1 requires a directory." + [[ -n $2 && $2 != -* ]] || fail "$1 requires a directory (use ./ for a name starting with '-')." + outputDir=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + buildFlags=("$@") + break + ;; + *) fail "Unknown argument: $1. See --help." ;; + esac +done +if [[ $outputDir != /* ]]; then + outputDir="$PWD/$outputDir" +fi +[[ ! -e $outputDir || -d $outputDir ]] || fail "Output directory is an existing file: $outputDir" +for binary in cthulhu cthulhu-braille; do + [[ ! -d $outputDir/$binary ]] || fail "Binary output path is a directory: $outputDir/$binary" +done +cd -- "$projectDir" + +[[ $(uname -s) == Linux ]] || fail "Cthulhu requires Linux." +export CGO_ENABLED=${CGO_ENABLED:-1} +[[ $CGO_ENABLED == 1 ]] || fail "Cthulhu requires CGO_ENABLED=1 and a C compiler." +command -v pkg-config >/dev/null || fail "Install pkg-config and the development libraries listed in CONTRIBUTING.md." +missingLibraries=() +for library in gtk+-3.0 atspi-2 gio-2.0 gstreamer-1.0 x11 xtst xfixes xres xkbcommon liblouis; do + if ! pkg-config --exists "$library"; then + missingLibraries+=("$library") + fi +done +if (( ${#missingLibraries[@]} )); then + fail "Missing development libraries (pkg-config names): ${missingLibraries[*]}. See CONTRIBUTING.md; BRLTTY/BrlAPI headers and library are also required." +fi + +requiredVersion=$(awk '$1 == "go" { print $2; exit }' go.mod) +[[ $requiredVersion =~ ^1\.[0-9]+\.[0-9]+$ ]] || fail "Cannot read the required Go version from go.mod." +export GOTOOLCHAIN=${GOTOOLCHAIN:-auto} +goCommand="" +if command -v go >/dev/null; then + installedVersion=$(GOTOOLCHAIN=local go version 2>/dev/null || true) + if [[ $installedVersion =~ go1\.([0-9]+) ]] && (( 10#${BASH_REMATCH[1]} >= 21 )); then + goCommand=$(command -v go) + fi +fi + +if [[ -z $goCommand ]]; then + [[ $GOTOOLCHAIN == auto ]] || fail "Install Go $requiredVersion or newer, or use GOTOOLCHAIN=auto to allow bootstrapping." + case $(uname -m) in + x86_64) archiveArch=amd64 ;; + aarch64|arm64) archiveArch=arm64 ;; + armv6l|armv7l) archiveArch=armv6l ;; + i386|i486|i586|i686) archiveArch=386 ;; + ppc64le|riscv64|s390x|loong64) archiveArch=$(uname -m) ;; + loongarch64) archiveArch=loong64 ;; + *) fail "No Go bootstrap archive configured for $(uname -m). Install Go 1.21 or newer." + esac + toolchainDir="${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains/go${requiredVersion}.linux-${archiveArch}" + goCommand="$toolchainDir/go/bin/go" + if [[ ! -x $goCommand ]]; then + for dependency in curl tar sha256sum; do + command -v "$dependency" >/dev/null || fail "Install $dependency to bootstrap Go, or install Go $requiredVersion or newer." + done + mkdir -p -- "$(dirname -- "$toolchainDir")" + downloadDir=$(mktemp -d "${toolchainDir}.download.XXXXXX") + trap 'rm -rf -- "$downloadDir"' EXIT + archiveURL="https://dl.google.com/go/go${requiredVersion}.linux-${archiveArch}.tar.gz" + echo "Downloading Go $requiredVersion for Linux/$archiveArch..." + curlFlags=(--fail --location --silent --show-error --proto '=https' --proto-redir '=https') + expectedChecksum=$(curl "${curlFlags[@]}" "$archiveURL.sha256") + [[ $expectedChecksum =~ ^[[:xdigit:]]{64}$ ]] || fail "Invalid Go archive checksum." + curl "${curlFlags[@]}" --output "$downloadDir/go.tar.gz" "$archiveURL" + printf '%s %s\n' "$expectedChecksum" "$downloadDir/go.tar.gz" | sha256sum --check --status || fail "Go archive checksum mismatch." + tar -xzf "$downloadDir/go.tar.gz" -C "$downloadDir" + [[ -x $downloadDir/go/bin/go ]] || fail "The downloaded archive does not contain Go." + # Publish a complete tree; another build may have populated the cache. + if [[ ! -x $goCommand ]]; then + mkdir -p -- "$toolchainDir" + mv -T -- "$downloadDir/go" "$toolchainDir/go" || [[ -x $goCommand ]] + fi + fi + unset GOROOT +fi + +goVersion=$("$goCommand" version) || fail "The selected Go toolchain could not run." +echo "Using $goVersion" +mkdir -p -- "$outputDir" +"$goCommand" build "${buildFlags[@]}" -o "$outputDir/cthulhu" ./cmd/cthulhu +"$goCommand" build "${buildFlags[@]}" -tags brlapi -o "$outputDir/cthulhu-braille" ./cmd/cthulhu-braille +echo "Cthulhu has been built: $outputDir/cthulhu and $outputDir/cthulhu-braille" From 9bd5c15dd4d0d55e0a6789f36bf59cbecea22d64 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 03:02:32 -0500 Subject: [PATCH 4/4] Updated cthulhu build script. Should work on systems even with old versions of go. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bfdf7d8..246e29b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,9 @@ Use `gofmt`, exported PascalCase identifiers, private camelCase identifiers, and 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. +Fix regressions in the shared subsystem when appropriate. Identify state ownership, lifetime, reset, and cancellation; keep native callbacks and input/focus handling bounded. Keep Tentacles external and responsive. + +X11/Xorg is the primary supported display environment. Wayland support is secondary and must not regress Xorg behavior. Xorg regressions are critical blockers for merging and release. If a Wayland change cannot preserve Xorg correctness, defer that change. Use capability checks and graceful degradation for Wayland support. Prefer core Go implementations for features users expect from the reader itself; use Tentacles for extensions.