diff --git a/README.md b/README.md index eddb3f6..fa57e96 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go. - [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification. - [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification. - [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance. +- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. - [Wayland keyboard access](WAYLAND_SUPPORT.md): shared protocol support, limitations, and verification. diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md new file mode 100644 index 0000000..bbc80b6 --- /dev/null +++ b/docs/testing/background-events.md @@ -0,0 +1,77 @@ +# Background accessibility events + +On Xorg, Cthulhu rejects known background control updates before resolving +selected descendants or capturing their text, values, attributes and geometry. +The native adapter owns this decision on the GLib thread. It uses the existing +foreground window and epoch; there is no additional window ownership cache. + +Events from another provider can be rejected using copied bus identities and +libatspi's cached role and application name, without querying that provider. +Events from another window on the same provider undergo the existing window +correlation before content capture. Lifecycle invalidation still runs, but a +background provider no longer clears another provider's cached document lines. + +Window creation, activation, deactivation, destruction and defunct events keep +their existing handling. Native alerts, notification daemons and supported +Steam toasts retain their notification paths. Menu popups retain existing +foreground checks. Same-provider autocomplete relations and tooltip candidates +continue through the existing event-loop ownership checks; they do not acquire +focus merely by passing the early filter. + +When notification metadata or a detached document's containing window is +unknown, the adapter retains the existing validation path. The optimization +does not introduce an X11 ownership requirement for native Wayland sessions. + +Orca's `src/orca/event_manager.py` was used as a reference for keeping global +window monitoring and filtering noisy content events before presentation. + +## Automated checks + +Run from the repository root with the normal native build dependencies: + +```sh +go test ./... -count=1 +go test -race ./internal/atspi ./internal/app ./internal/review -count=1 +python3 tests/native_test_session.py env CTHULHU_NATIVE_BACKGROUND_EVENTS=1 CTHULHU_NATIVE_WAYLAND_POLICY=1 go test -race ./internal/atspi -run '^TestNative(BackgroundEventFiltering|WaylandForegroundPolicy)$' -count=1 -v -timeout=40s +python3 tests/native_webscripts.py +CTHULHU_NATIVE_BROWSER_TESTS=browser,tabs,live,tooltips python3 tests/native_browser.py +go build -o /tmp/cthulhu-background-events ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-background-events python3 tests/native_ownership.py +``` + +The flood regression uses two real GTK providers, then two windows on one +provider. Each burst changes the table selection 300 times. A private D-Bus +monitor asserts that the rejected updates cause no Selection, Table, Text or +Value interface calls. The explicit alert used as a completion marker is +excluded from this content-query assertion. Returning to the noisy window must +deliver its current table selection. The separate cache regression verifies +that invalidation preserves other providers' document lines. + +All native checks use disposable X servers and private D-Bus/runtime sockets. +They do not replace the active desktop reader. Browser checks cover address +suggestions, page controls, tabs, live regions and tooltips. Ownership checks +cover GTK/Qt menus, a GTK menu popup, dialogs, same-process windows, empty +workspaces and Brave window recovery. Notification checks preserve Steam toast +capture while rejecting ordinary background Chromium text changes. + +## Live acceptance + +Private fixtures do not establish audible speech, physical Braille or actual +FastSMRW update latency. After reviewing the diff, preserve the current debug +log and rebuild/start the candidate through the normal local launcher when +ready to replace the reader. + +1. Read headings and lines in Brave while FastSMRW receives new items. Compare + responsiveness with quiet periods. Background selection changes should not + delay reading or take speech/focus ownership. +2. Switch to FastSMRW and move through results. Confirm the current selection + is spoken; open its Open Links popup, move between links, and close it. +3. Switch between two windows from the same application. Open and close a + dialog and a menu; confirm speech follows the active control. +4. In Brave, type into the address bar and move through suggestions. Return to + the page, switch tabs, and verify normal editing and reading still work. +5. Check supported background notifications. If using Braille, confirm it + follows the foreground control and still displays supported notifications. + +Preserve a fresh log if lag or a missing announcement remains, with the time +and the key or update that triggered it. Debug logs can contain private text. diff --git a/docs/testing/reading-settings.md b/docs/testing/reading-settings.md index 3e9c79b..a0407b6 100644 --- a/docs/testing/reading-settings.md +++ b/docs/testing/reading-settings.md @@ -5,7 +5,7 @@ Build a separate test binary and run the focused checks: ```sh go build -o /tmp/cthulhu-reading-descriptions ./cmd/cthulhu go test ./internal/preferences ./internal/presentation -count=1 -go test ./internal/app -run 'GUITable|PauseChunks' -count=1 +go test ./internal/app -run 'GUITable|GUIListTable|Context|PauseChunks' -count=1 CTHULHU_BINARY=/tmp/cthulhu-reading-descriptions CTHULHU_PREFS_READING_ONLY=1 /usr/bin/python3 tests/native_preferences.py ``` @@ -33,6 +33,19 @@ session: 6. Check the modifier-key table and an application table to confirm their existing row and cell reading still works. Check the new descriptions on a physical Braille display when available. +7. In FastSMRW, move through the timeline and switch workspaces to a terminal. + Confirm no enter/leave container announcements occur. Also Tab into and out + of Preferences tables and a native application's list: names, rows and cells + should still be spoken, without a separate container exit announcement. +8. In browser browse mode, navigate into and out of a list and data table. + Confirm enabled context announcements still work, including during Say All. + Switch to focus mode, browser chrome or another workspace and confirm there + is no container exit announcement. Return to browse mode and check context + is announced again when entering a container. +9. Focus a button with a mnemonic, such as Preferences' Apply button. With + pause breaks enabled, confirm the mnemonic follows as a separate chunk. + Disable pause breaks and confirm the announcement is combined. Restore the + preferred setting. Automated checks do not establish audible speech, physical keyboard or Braille acceptance, or behavior of the installed reader. diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go index b4acf81..b87c106 100644 --- a/internal/app/browser_mode.go +++ b/internal/app/browser_mode.go @@ -248,7 +248,7 @@ func (browser *browserModeController) set(mode review.DocumentMode, speech speec cue = "focus_mode" } // Append automatic changes so they do not replace the focused control. - speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual}) + speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual, ResetContext: mode != review.BrowseMode}) } func (browser *browserModeController) toggle(speech speechEnqueuer) { if !browser.ready || browser.mode == review.InactiveMode { diff --git a/internal/app/event_gui_table_test.go b/internal/app/event_gui_table_test.go index 7abfc11..2c6a803 100644 --- a/internal/app/event_gui_table_test.go +++ b/internal/app/event_gui_table_test.go @@ -139,17 +139,13 @@ func TestNativeGUIListTableSpeech(t *testing.T) { } text := strings.Join(parts, " | ") t.Log(text) - if strings.Count(text, activation.WindowDescription) != 1 || strings.Count(text, "Leaving Probe items list.") != 1 || - strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 2 { + if strings.Count(text, activation.WindowDescription) != 1 || strings.Contains(text, "Leaving ") || + strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 1 { t.Fatalf("unexpected launcher speech: %s", text) } - // Exiting through Shift+Tab should produce the only list-exit message. - if len(parts) < 3 || parts[len(parts)-3] != "Leaving Probe items list." { - t.Fatalf("list exit occurred before returning to the entry: %s", text) - } } -func TestGUIListTableNameHonorsContextSettings(t *testing.T) { +func TestGUIListTableNameWithoutBrowseContext(t *testing.T) { for _, contextEnabled := range []bool{true, false} { state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() @@ -162,10 +158,7 @@ func TestGUIListTableNameHonorsContextSettings(t *testing.T) { event := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell) result := state.prepareGUITable(event, 1) spoken, _ := state.decorateGUITable(event, presentation.Speech{Text: "Alpha"}, result) - want := "Alpha" - if !contextEnabled { - want = "Results, Alpha" - } + want := "Results, Alpha" if spoken.Text != want { t.Fatalf("list context enabled=%t: got %q, want %q", contextEnabled, spoken.Text, want) } diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index b6537fc..31b82d5 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -213,8 +213,9 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu (event.Type == atspi.CaretEvent && intent == input.LineIntent || isFocusGainEvent(event)) { line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine} } + browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName speech.Enqueue(speechRequest{ - Context: event.Source.SpeechContext, VoiceType: ancestor_voice(event.Source.SpeechContext), + Context: event.Source.SpeechContext, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext), Window: eventWindow(event), IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) && activationTitle(event) != "", diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index d0be47f..c3ef29c 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -63,7 +63,7 @@ func (handler *eventReviewHandler) handle(result review.Result) { handler.refreshPresentation() state := handler.state logger := handler.logger - speech := handler.speech + speech := contextSpeechQueue{speechEnqueuer: handler.speech, enabled: handler.browser.speech_context_enabled()} objectReview := handler.objectReview browser := handler.browser brailleOutput := handler.brailleOutput diff --git a/internal/app/gui_table_presentation.go b/internal/app/gui_table_presentation.go index 3c05a24..feebcfa 100644 --- a/internal/app/gui_table_presentation.go +++ b/internal/app/gui_table_presentation.go @@ -9,7 +9,6 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" - "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" ) @@ -67,22 +66,7 @@ func (state *eventLoopState) decorateGUITable(event atspi.Event, spoken presenta result.table != state.lastTable && event.Source.TableName != "" && (isFocusGainEvent(event) || isManagedDescendantEvent(event.Type)) && event.Type != atspi.WindowActivatedEvent { - // A GTK table exposed as a list already gets its name from list - // context. Retain the table label when that context is disabled. - listContext := false - for _, ancestor := range event.Source.SpeechContext { - if ancestor.ID() == result.table && ancestor.Role == "list" && ancestor.Name == event.Source.TableName { - reading := config.DefaultSettings().Reading - if result.options.Reading != nil { - reading = *result.options.Reading - } - listContext = presentation.ContextEnabled("list", reading) - break - } - } - if !listContext { - spoken = spoken.Prepend(event.Source.TableName, ", ") - } + spoken = spoken.Prepend(event.Source.TableName, ", ") } appendFocus := isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && state.tableDescriptionWindow.Valid() && event.Window == state.tableDescriptionWindow && diff --git a/internal/app/pause_chunks_test.go b/internal/app/pause_chunks_test.go index b795bb6..2b51b4e 100644 --- a/internal/app/pause_chunks_test.go +++ b/internal/app/pause_chunks_test.go @@ -127,6 +127,34 @@ type PauseWaitSpeaker struct { line int } +func TestPauseChunksIncludeMnemonics(t *testing.T) { + for _, enabled := range []bool{false, true} { + settings := config.DefaultSettings() + settings.EnableSound = false + settings.Reading.EnablePauseBreaks = enabled + live := &liveSettings{} + live.Store(settings) + logger := slog.New(slog.DiscardHandler) + queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)} + presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live} + presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{ + Name: "Open", Role: "button", Mnemonic: "o", + })) + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(logger, speaker) + if err := worker.deliver(context.Background(), <-queue.requests); err != nil { + t.Fatal(err) + } + want := []string{"Open button Alt o"} + if enabled { + want = []string{"Open", "button", "Alt o"} + } + if !reflect.DeepEqual(speaker.parts, want) { + t.Fatalf("pause breaks=%v: speech=%q, want %q", enabled, speaker.parts, want) + } + } +} + func (speaker *PauseWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, began func()) error { if began != nil { began() diff --git a/internal/app/reading_context.go b/internal/app/reading_context.go index c9ea6d1..a35145a 100644 --- a/internal/app/reading_context.go +++ b/internal/app/reading_context.go @@ -9,6 +9,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" ) func (worker *speechWorker) context_messages(ancestors []atspi.Snapshot) []string { @@ -72,9 +73,29 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say } func (worker *speechWorker) deliver_context(ctx context.Context, request speechRequest) error { + if request.ResetContext { + worker.speechContext = nil + return nil + } return worker.deliver_context_for(ctx, request.Context, false) } +func (browser *browserModeController) speech_context_enabled() bool { + return browser != nil && browser.ready && browser.mode == review.BrowseMode && browser.document.Valid() +} + +// Review results can also come from focus mode or native text. Capture the +// mode on the event loop rather than reading browser state on speech delivery. +type contextSpeechQueue struct { + speechEnqueuer + enabled bool +} + +func (queue contextSpeechQueue) Enqueue(request speechRequest) { + request.ResetContext = request.ResetContext || !queue.enabled + queue.speechEnqueuer.Enqueue(request) +} + func (worker *speechWorker) deliver_context_for(ctx context.Context, ancestors []atspi.Snapshot, sayAll bool) error { for _, message := range worker.context_messages_for(ancestors, sayAll) { worker.logDelivery(ctx, speechRequest{Role: "system", Cause: "reading-context", Append: true}, message, -1) diff --git a/internal/app/reading_context_test.go b/internal/app/reading_context_test.go new file mode 100644 index 0000000..88a427b --- /dev/null +++ b/internal/app/reading_context_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestContextAnnouncementsOnlyInBrowseMode(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} + speaker := &spellingTestSpeaker{} + worker := newSpeechWorker(logger, speaker) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + browser := &browserModeController{} + state := &eventLoopState{} + state.presentationOptions = presentation.DefaultOptions() + presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser} + table := atspi.Snapshot{BusName: ":1.42", Path: "/table", Role: "table", Name: "Timeline"} + control := atspi.Snapshot{BusName: table.BusName, Path: "/item", Role: "text", Name: "Item", + SpeechContext: []atspi.Snapshot{table}} + check := func(snapshot atspi.Snapshot, want []string) { + t.Helper() + // Each step represents fresh focus, even if its label is unchanged. + state.lastSpokenPath = "" + presenter.speak(atspi.NewEvent(atspi.FocusEvent, snapshot)) + speaker.parts = nil + if len(queue.requests) != 1 { + t.Fatalf("queued %d requests", len(queue.requests)) + } + if err := worker.deliver(context.Background(), <-queue.requests); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(speaker.parts, want) { + t.Fatalf("speech = %q, want %q", speaker.parts, want) + } + } + check(control, []string{"Item", "text"}) + terminal := atspi.Snapshot{BusName: ":1.43", Path: "/terminal", Role: "terminal", Name: "Terminal", + SpeechContext: []atspi.Snapshot{}} + check(terminal, []string{"Terminal", "terminal"}) + if len(worker.speechContext) != 0 { + t.Fatal("native context was retained") + } + browser.ready, browser.mode = true, review.BrowseMode + browser.document = atspi.NewObjectID(table.BusName, "/document") + check(control, []string{"Timeline table", "Item", "text"}) + outside := control + outside.Name, outside.SpeechContext = "Outside", []atspi.Snapshot{} + check(outside, []string{"Leaving Timeline table.", "Outside", "text"}) + check(control, []string{"Timeline table", "Item", "text"}) + // A workspace switch must clear browser context without saying its exit, + // even while asynchronous browser discovery still holds the old mode. + check(terminal, []string{"Terminal", "terminal"}) + check(control, []string{"Timeline table", "Item", "text"}) + browser.mode = review.FocusMode + check(control, []string{"Item", "text"}) + browser.mode = review.BrowseMode + check(control, []string{"Timeline table", "Item", "text"}) + browser.ready = false + check(control, []string{"Item", "text"}) +} + +func TestReviewContextSuppressedOutsideBrowseMode(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + for _, enabled := range []bool{false, true} { + queue := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} + scoped := contextSpeechQueue{speechEnqueuer: queue, enabled: enabled} + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + speaker := &PauseWaitSpeaker{} + worker := newSpeechWorker(logger, speaker) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + worker.speechContext = []atspi.Snapshot{{BusName: ":1.42", Path: "/previous", Role: "table"}} + scoped.Enqueue(speechRequest{ReadAll: []readAllPart{{Text: "Inside", Context: []atspi.Snapshot{list}}}}) + if err := worker.deliver(context.Background(), <-queue.requests); err != nil { + t.Fatal(err) + } + want := []string{"Inside"} + if enabled { + want = []string{"Leaving table.", "Items list", "Inside"} + } + if !reflect.DeepEqual(speaker.parts, want) { + t.Fatalf("browse=%v: speech=%q, want %q", enabled, speaker.parts, want) + } + } +} diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go index 842bb7a..923aa41 100644 --- a/internal/app/speech_delivery.go +++ b/internal/app/speech_delivery.go @@ -83,7 +83,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest) if err := ctx.Err(); err != nil { return err } - if part.Context != nil { + if part.Context != nil && !request.ResetContext { if err := worker.deliver_context_for(ctx, part.Context, true); err != nil { return err } diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go index 4ef8052..a57fcb8 100644 --- a/internal/app/speech_worker.go +++ b/internal/app/speech_worker.go @@ -50,6 +50,7 @@ type speechRequest struct { VoiceType string Line *speechLine Context []atspi.Snapshot + ResetContext bool // Silently discard browse context outside browse mode. Spelling []string // One cancellable queue item, with separate letter utterances. SpellingVoices []string Sound []sound.Cue diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index 01a1274..1cef324 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -132,6 +132,7 @@ type Adapter struct { treeRequests chan treeCaptureRequest overflow atomic.Uint64 treeInvalidations atomic.Uint64 + backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries secureMutex sync.Mutex secureContext context.Context secureCancel context.CancelFunc diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index a0265f1..27fe7e0 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -455,6 +455,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) { return } + if adapter.filterNativeBackgroundEvent(event, eventType, originID, webNotification != nil) { + adapter.backgroundEventsFiltered.Add(1) + return + } if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 { return } diff --git a/internal/atspi/adapter_event_filter_native.go b/internal/atspi/adapter_event_filter_native.go new file mode 100644 index 0000000..f5b9ea9 --- /dev/null +++ b/internal/atspi/adapter_event_filter_native.go @@ -0,0 +1,71 @@ +// 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" + +// Reject routine background updates before resolving a selected descendant or +// capturing text, attributes, values and geometry. Global lifecycle events and +// supported notifications retain their existing handling. No new ownership is +// inferred from a background control's FOCUSED or SELECTED state. +func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventType string, origin ObjectID, webNotification bool) bool { + switch eventType { + case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent: + return false + } + foreground := adapter.Foreground() + if !foreground.Client.Known || webNotification { + return false + } + // This reads libatspi's cached role/application name only. Missing metadata + // takes the established path so a newly published alert is not lost. + if C.cthulhu_may_be_background_notification(event.source) != 0 { + return false + } + if foreground.Client.Window == 0 || adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName { + return true + } + window := C.cthulhu_window_ancestor(event.source) + if window == nil { + // Chromium can detach its retained document while replacing the frame. + // The existing recovery path must resolve that association. + return false + } + defer C.g_object_unref(C.gpointer(window)) + focusedMenuItem := false + if eventType == FocusEvent || eventType == FocusedStateEvent && event.detail1 != 0 { + switch C.atspi_accessible_get_role(event.source, nil) { + case C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_CHECK_MENU_ITEM, C.ATSPI_ROLE_RADIO_MENU_ITEM: + focusedMenuItem = true + } + } + if adapter.matchForeground(window, nativeObjectID(window), nil, focusedMenuItem) { + return false + } + if origin.BusName == adapter.foregroundWindow.BusName { + // Browser suggestions and tooltips can occupy a different top-level + // without taking X11 focus. Preserve their existing event-loop checks; + // a popup-for relation alone never grants presentation ownership here. + role := C.atspi_accessible_get_role(event.source, nil) + if eventType == ShowingStateEvent && role == C.ATSPI_ROLE_TOOL_TIP { + return false + } + switch role { + case C.ATSPI_ROLE_LIST, C.ATSPI_ROLE_LIST_BOX, C.ATSPI_ROLE_LIST_ITEM, + C.ATSPI_ROLE_MENU, C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_PUSH_BUTTON, C.ATSPI_ROLE_TOGGLE_BUTTON: + if popupFor := C.cthulhu_autocomplete_popup_target(event.source); popupFor != nil { + id := nativeObjectID(popupFor) + C.g_object_unref(C.gpointer(popupFor)) + if id.Valid() && id.BusName == origin.BusName { + return false + } + } + } + } + return true +} diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 099210a..e55068c 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -521,6 +521,22 @@ const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source) { return ATSPI_OBJECT(source)->app->bus_name; } +// Cache-only classification: never contact an unrelated provider just to +// decide whether its event can be ignored. Unknown cache entries retain the +// ordinary validation path, including native alerts and notification daemons. +gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) { + if (source == NULL || !(source->cached_properties & ATSPI_CACHE_ROLE)) return TRUE; + if (source->role == ATSPI_ROLE_ALERT || source->role == ATSPI_ROLE_NOTIFICATION) return TRUE; + AtspiApplication *application = source->parent.app; + AtspiAccessible *root = application != NULL ? application->root : NULL; + if (root == NULL || !(root->cached_properties & ATSPI_CACHE_NAME)) return TRUE; + const gchar *name = root->name != NULL ? root->name : ""; + return g_ascii_strcasecmp(name, "notification-daemon") == 0 || + g_ascii_strcasecmp(name, "mate-notification-daemon") == 0 || + g_ascii_strcasecmp(name, "xfce4-notifyd") == 0 || + g_ascii_strcasecmp(name, "notify-osd") == 0; +} + // Return the containing top-level window. An embedded web dialog is part of // its frame; a standalone dialog directly below the application owns itself. AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) { diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 14831a4..b7aa1cb 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -129,6 +129,7 @@ AtspiAccessible *cthulhu_inline_menu_container(AtspiAccessible *source); const gchar *cthulhu_accessible_path(AtspiAccessible *source); const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source); AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source); +gboolean cthulhu_may_be_background_notification(AtspiAccessible *source); AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source); AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source); AtspiEventListenerCB cthulhu_event_callback_pointer(void); diff --git a/internal/atspi/adapter_tree_native.go b/internal/atspi/adapter_tree_native.go index d28ba28..914f080 100644 --- a/internal/atspi/adapter_tree_native.go +++ b/internal/atspi/adapter_tree_native.go @@ -503,7 +503,12 @@ func (adapter *Adapter) invalidateTreeEventWithPolicy(eventType string, detail1 return } if eventType != CaretEvent { - clear(adapter.documentLineCache) + // Background providers cannot change the owned document's text lines. + for id := range adapter.documentLineCache { + if id.BusName == sourceID.BusName { + delete(adapter.documentLineCache, id) + } + } } adapter.treeMutex.Lock() trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} diff --git a/internal/atspi/background_events_native_test.go b/internal/atspi/background_events_native_test.go new file mode 100644 index 0000000..3b2118c --- /dev/null +++ b/internal/atspi/background_events_native_test.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +func TestBackgroundInvalidationPreservesOtherProviderLines(t *testing.T) { + owned := NewObjectID(":1.42", "/line") + other := NewObjectID(":1.43", "/line") + adapter := New() + adapter.documentLineCache = map[ObjectID]DocumentCaretText{owned: {}, other: {}} + adapter.invalidateTreeEvent(TextInsertedEvent, 0, other, ObjectID{}) + if _, ok := adapter.documentLineCache[owned]; !ok { + t.Fatal("background text change cleared the owned provider's line cache") + } + if _, ok := adapter.documentLineCache[other]; ok { + t.Fatal("changed provider's line cache survived invalidation") + } + adapter.invalidateTreeEvent(AccessibleNameEvent, 0, owned, ObjectID{}) + if len(adapter.documentLineCache) != 0 { + t.Fatal("owned provider change failed to invalidate its lines") + } +} + +func TestNativeBackgroundEventFiltering(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BACKGROUND_EVENTS") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + + type fixture struct { + input io.WriteCloser + output *bufio.Reader + xid string + } + start := func(name string) fixture { + command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_background_events.py", name) + input, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + output, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() }) + reader := bufio.NewReader(output) + line, err := reader.ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 2 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return fixture{input, reader, fields[1]} + } + wait := func(predicate func(Event) bool) Event { + t.Helper() + var last Event + count := 0 + for { + select { + case event := <-adapter.Events(): + last = event + count++ + if predicate(event) { + return event + } + case <-ctx.Done(): + t.Fatalf("native event missing: %v; filtered=%d received=%d last=%s %s %q", ctx.Err(), adapter.backgroundEventsFiltered.Load(), count, last.Type, last.Source.Role, last.Source.Name) + } + } + } + focus := func(xid string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", xid).CombinedOutput(); err != nil { + t.Fatalf("focus: %s, %v", output, err) + } + } + background := start("Background fixture") + foreground := start("Foreground fixture") + // Publish the complete AT-SPI cache, including table cells, before flooding. + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + focus(foreground.xid) + fmt.Fprintln(foreground.input, "edit") + owned := wait(func(event Event) bool { + return event.Type == TextInsertedEvent && event.Payload == "Updated text" && !event.ForegroundMismatch + }) + + sessionBus, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer sessionBus.Close() + var address string + if err := sessionBus.Object("org.a11y.Bus", "/org/a11y/bus").CallWithContext(ctx, "org.a11y.Bus.GetAddress", 0).Store(&address); err != nil { + t.Fatal(err) + } + control, err := dbus.Connect(address) + if err != nil { + t.Fatal(err) + } + defer control.Close() + monitor, err := dbus.Connect(address) + if err != nil { + t.Fatal(err) + } + defer monitor.Close() + if err := monitor.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, []string{"type='method_call'"}, uint32(0)).Err; err != nil { + t.Fatal(err) + } + messages := make(chan *dbus.Message, 65536) + monitor.Eavesdrop(messages) + + flood := func(generation string, sameProvider bool) { + t.Helper() + before := adapter.backgroundEventsFiltered.Load() + fmt.Fprintln(background.input, "flood "+generation) + marker := wait(func(event Event) bool { + return event.Type == TextInsertedEvent && event.Payload == "Flood complete "+generation + }) + if (marker.Source.BusName == owned.Source.BusName) != sameProvider { + t.Fatal("fixture provider identity did not match test case") + } + if count := adapter.backgroundEventsFiltered.Load() - before; count < 300 { + t.Fatalf("filtered %d events; want at least 300 real selection updates", count) + } + if err := control.Object(marker.Source.BusName, dbus.ObjectPath(marker.Source.Path)).CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0).Err; err != nil { + t.Fatal(err) + } + queries := 0 + reading: + for { + select { + case message := <-messages: + if message.Headers[dbus.FieldDestination].Value() != marker.Source.BusName { + continue + } + member := message.Headers[dbus.FieldMember].Value() + if member == "Ping" { + break reading + } + if message.Headers[dbus.FieldPath].Value() == dbus.ObjectPath(marker.Source.Path) { + continue + } + switch message.Headers[dbus.FieldInterface].Value() { + case "org.a11y.atspi.Selection", "org.a11y.atspi.Table", "org.a11y.atspi.Text", "org.a11y.atspi.Value": + queries++ + } + case <-ctx.Done(): + t.Fatal("monitor barrier missing") + } + } + if queries != 0 { + t.Fatalf("background flood caused %d selection/table/text/value queries", queries) + } + t.Logf("%s: filtered at least 300 updates with zero content queries", generation) + } + flood("other-provider", false) + + // The same application can have an inactive sibling on the same bus. + fmt.Fprintln(background.input, "sibling") + line, err := background.output.ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 2 || fields[0] != "sibling" { + t.Fatalf("sibling readiness: %q, %v", line, err) + } + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + focus(fields[1]) + owned = wait(func(event Event) bool { + return !event.ForegroundMismatch && event.Source.Name == "Background fixture sibling entry" + }) + flood("same-provider", true) + + // Returning to the previously noisy window must read its current selection. + focus(background.xid) + fmt.Fprintln(background.input, "focus-table") + wait(func(event Event) bool { + return !event.ForegroundMismatch && event.Source.Name == "Result 1" && event.Source.GUITable + }) + t.Log("returning to the noisy application delivers current table focus") +} diff --git a/internal/atspi/webscripts_native_test.go b/internal/atspi/webscripts_native_test.go index 2431b73..cd53b2e 100644 --- a/internal/atspi/webscripts_native_test.go +++ b/internal/atspi/webscripts_native_test.go @@ -62,6 +62,7 @@ func TestNativeWebScriptNotificationCapture(t *testing.T) { fmt.Fprintln(input, "show") if processName == "chromium" { fmt.Fprintln(input, "update") + fmt.Fprintln(input, "barrier") } var root ObjectID for !root.Valid() { @@ -71,7 +72,10 @@ func TestNativeWebScriptNotificationCapture(t *testing.T) { continue } if processName == "chromium" { - if event.Type != TextInsertedEvent || event.Payload != "Username is now online" { + if event.Type == TextInsertedEvent && event.Payload == "Username is now online" { + t.Fatal("ordinary background Chromium text reached snapshot delivery") + } + if event.Type != TextInsertedEvent || event.Payload != "Notification barrier" || event.Source.Role != "alert" { continue } if event.WebNotification != nil { diff --git a/internal/preferences/native_reading.c b/internal/preferences/native_reading.c index d2e32a7..2db8ff2 100644 --- a/internal/preferences/native_reading.c +++ b/internal/preferences/native_reading.c @@ -38,17 +38,17 @@ static const struct { const char *id; const char *label; const char *description {"speak_misspelled_indicator", "Speak misspelled-word indicator", "Says misspelled when reading text that the application's spell checker has marked as incorrect."}, {"speak_context_blockquote", "Announce block quotes during navigation", - "Announces when you enter or leave a block quote while navigating text."}, + "Announces when you enter or leave a block quote in browser browse mode."}, {"speak_context_list", "Announce lists during navigation", - "Announces when you enter or leave a list while navigating."}, + "Announces when you enter or leave a list in browser browse mode."}, {"speak_context_table", "Announce tables during navigation", - "Announces when you enter or leave a data table while navigating."}, + "Announces when you enter or leave a data table in browser browse mode."}, {"speak_context_panel", "Announce panels during navigation", - "Announces when you enter or leave a panel or group of controls while navigating."}, + "Announces when you enter or leave a panel or group of controls in browser browse mode."}, {"speak_context_landmark", "Announce landmarks during navigation", - "Announces when you enter or leave a landmark, such as a page's main content or navigation region."}, + "Announces when you enter or leave a landmark in browser browse mode, such as a page's main content or navigation region."}, {"speak_context_non_landmark_form", "Announce forms during navigation", - "Announces when you enter or leave a form that is not identified as a landmark."}, + "Announces when you enter or leave a form that is not a landmark in browser browse mode."}, {"read_full_row_in_gui_table", "Speak full row in GUI tables", "Reads all cells in a row when moving between rows in application tables, including this settings table. Left and Right read individual cells."}, {"read_full_row_in_document_table", "Speak full row in document tables", diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index b9a0aff..969dd31 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -10,6 +10,7 @@ lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak lifecycle-start-sound,src/cthulhu/sound_theme_manager.py,sound,Play the startup sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow)$' -count=1,Maintainer heard the startup cue during the 2026-09-11 physical Xorg preview; the desktop and window manager were not recorded.,,,,"Python plays the start cue from the configured theme when enableSound is true and the theme is not none; startup uses a nonblocking, interrupting, best-effort helper command with no per-cue deadline at this call. Go creates the sound service only when enableSound is true, theme is not none, and volume is greater than zero; it enqueues start after service startup into a bounded 8-cue queue, with a 3-second playback deadline, and can drop the startup cue on overflow. audit-required: Python helper-process and Go in-process queue saturation are not directly compared by live evidence. September 25 review: nonblocking queue drains prevent a consumer/producer deadlock during cue replacement and live reconfiguration; concurrent regression and race checks pass." lifecycle-stop-sound,src/cthulhu/sound_theme_manager.py,sound,Play the graceful-shutdown sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow|TestStopTimeoutPreservesShutdownCueForRetry)$' -count=1; go test ./internal/app -run '^(TestRunReachesSoundStopAfterEarlierServiceStopError|TestRunReportsSoundStopError)$' -count=1,Maintainer heard the post-fix stop cue through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11; the launcher returned to the prompt and completed final shutdown records.,,,,"Python plays stop only when enableSound is true and the theme is not none; it interrupts current sound, waits up to 1 second through the helper, then continues shutdown. Go runs stop as the final reverse lifecycle service with its own 3-second deadline, cancels active cues, discards pending cues, and preserves a timed-out stop cue for one retry. audit-required: Python helper-isolation versus Go in-process cancellation and retry ordering are not directly aligned by the named evidence." focus-events,src/cthulhu/event_manager.py,focus,Receive focus events and capture immutable accessible snapshots with stable object paths,Storm Dragon,internal/atspi,implemented,go test ./internal/atspi ./internal/app ./internal/logging -count=1,GTK yad Zenity and LXTerminal live tests passed 2026-09-10; post-fix AT-SPI shutdown passed through cthulhu.sh with its LXTerminal test application on Xorg 2026-09-11.,,,,Queue overflow duplicate-object suppression and nonblocking diagnostics have automated coverage; physical diagnostics saturation remains pending. +background-event-filtering,Orca src/orca/event_manager.py,focus,Reject routine background updates before selected-child and content capture,,internal/atspi,in-progress,go test ./... -count=1; private background flood and notification checks; docs/testing/background-events.md,Private GTK floods from another provider and same-provider sibling produce zero selection/table/text/value queries; GTK Qt Brave ownership and browser checks pass,Private Wayland policy check preserves AT-SPI ownership without X11 authority,Actual FastSMRW and Brave listening pending,Physical Braille pending,Existing lifecycle notification menu autocomplete and detached-document validation paths are preserved; unrelated providers no longer clear owned text-line caches. xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu while XTerm owns focus and restore when a known non-XTerm window takes focus,Storm Dragon,internal/app internal/atspi internal/platform/xorg,in-progress,NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/app -run '^(TestXTermFocus|TestProcessEvents)' -count=1; NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/platform/xorg -count=1; python3 tests/native_x11.py,Private Xvfb/D-Bus proof passes: XTerm focus suspends speech and command grabs; Insert+Q reaches a real XTerm; an unknown active window remains suspended; a known non-XTerm window restores all existing command and release checks.,,Pending human listening with Fenrir in XTerm,,Follow-up gates delegated modifier interruption and echo; commands dispatch on press with balanced shutdown draining. Go retains the existing XTerm identity matching focus monitoring grab suspension and event suppression boundary. Notifications bypass suppression. Unknown active windows remain suspended so an incomplete match cannot strand a delegated terminal reader; live Fenrir handoff remains pending. focus-speech,src/cthulhu/scripts/default.py,speech,Speak the focused accessible object including common control states and range values,Storm Dragon,internal/atspi internal/presentation internal/app,in-progress,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst.,,Maintainer reported that all combined YAD I38 and LibreOffice tests worked great on 2026-09-12.,,Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Physical listening pending. terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,in-progress,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11.,,Maintainer heard terminal caret speech during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; broader terminal application and editing coverage remains pending. diff --git a/tests/gtk_background_events.py b/tests/gtk_background_events.py new file mode 100644 index 0000000..11b0be7 --- /dev/null +++ b/tests/gtk_background_events.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Controlled selection flood and same-process sibling window fixture.""" +import os +import sys + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Atk", "1.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Atk, GdkX11, GLib, Gtk + + +fixtureName = sys.argv[1] +windows = [] +pendingInput = "" + + +def make_window(title): + window = Gtk.Window(title=title) + window.set_default_size(350, 260) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + window.add(box) + entry = Gtk.Entry() + entry.get_accessible().set_name(title + " entry") + box.add(entry) + store = Gtk.ListStore(str) + for index in range(40): + store.append(["Result " + str(index)]) + table = Gtk.TreeView(model=store) + table.get_accessible().set_name(title + " results") + table.append_column(Gtk.TreeViewColumn("Result", Gtk.CellRendererText(), text=0)) + box.add(table) + marker = Gtk.Label(label="Marker") + marker.get_accessible().set_role(Atk.Role.ALERT) + box.add(marker) + window.show_all() + entry.grab_focus() + windows.append(window) + return window, entry, table, marker + + +window, entry, table, marker = make_window(fixtureName) +sibling = None + + +def receive_command(_source, _condition): + global pendingInput, sibling + content = os.read(sys.stdin.fileno(), 4096) + if not content: + Gtk.main_quit() + return False + pendingInput += content.decode() + while "\n" in pendingInput: + command, pendingInput = pendingInput.split("\n", 1) + if command.startswith("flood "): + generation = command.split()[1] + selection = table.get_selection() + for index in range(300): + selection.select_path(Gtk.TreePath.new_from_indices([index % 40])) + marker.set_text("Flood complete " + generation) + elif command == "sibling": + sibling, _, _, _ = make_window(fixtureName + " sibling") + print("sibling", GdkX11.X11Window.get_xid(sibling.get_window()), flush=True) + elif command == "edit": + entry.set_text("Updated text") + entry.set_position(-1) + elif command == "focus-table": + table.grab_focus() + table.set_cursor(Gtk.TreePath.new_from_indices([1])) + elif command == "quit": + Gtk.main_quit() + return False + return True + + +GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command) +GLib.idle_add(lambda: print("ready", GdkX11.X11Window.get_xid(window.get_window()), flush=True) or False) +Gtk.main() diff --git a/tests/gtk_web_notification.py b/tests/gtk_web_notification.py index 9e8671a..0c69435 100644 --- a/tests/gtk_web_notification.py +++ b/tests/gtk_web_notification.py @@ -26,6 +26,9 @@ body = Gtk.Label(label="Playing: Example Game") toast.add(username) toast.add(body) box.add(toast) +barrier = Gtk.Label(label="Marker") +barrier.get_accessible().set_role(Atk.Role.ALERT) +box.add(barrier) window.connect("destroy", Gtk.main_quit) window.show_all() foreground = Gtk.Window(title="Foreground fixture") @@ -52,6 +55,8 @@ def receive_command(source: object, condition: object) -> bool: toast.show_all() elif command == "update": body.set_text("Username is now online") + elif command == "barrier": + barrier.set_text("Notification barrier") elif command == "destroy": toast.destroy() elif command == "add":