diff --git a/README.md b/README.md index 855bdcc..c6cf15e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ Cthulhu is a Linux screen reader written in Go. -- [2026.10.31 release notes](docs/releases/2026.10.31.md): upgrading from Python, building, packaging, and release verification. - [Repository Guidelines](AGENTS.md): layout, commands, and working safeguards. - [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. @@ -15,7 +14,7 @@ Cthulhu is a Linux screen reader written in Go. - [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification. - [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. -- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification. +- [Audacious GUI](docs/testing/audacious.md): isolated Qt/GTK menu and playlist checks, row-focus fixes, and live verification. - [Tentacle updates](docs/testing/tentacle-updates.md): package recovery, version comparison, and update verification. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). @@ -128,7 +127,6 @@ The option is available in global and application preferences. Apply saves it; application choices override the global value. Sticky browse mode still keeps browse mode, and sticky focus mode still keeps focus mode. Use Cthulhu+A to switch back to browse mode when finished interacting. -See [automatic focus verification](docs/testing/structural-focus.md). ## Emoji menu @@ -142,8 +140,6 @@ without using the clipboard. Browse mode blocks typing. Checked copies the emoji without typing it. The choice is remembered for the current reader session. Escape cancels. -See [emoji menu verification](docs/testing/emoji-menu.md) for live checks. - ## Key echo The Key Echo preferences page offers three radio choices: Disable key echo, diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index f135a25..0569f03 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.29.r581.g44581c4 +pkgver=2026.10.31.r588.g9680b58 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/docs/testing/audacious.md b/docs/testing/audacious.md new file mode 100644 index 0000000..ca16dc7 --- /dev/null +++ b/docs/testing/audacious.md @@ -0,0 +1,32 @@ +# Audacious GUI verification + +The shared AT-SPI table handling recognizes Qt trees exposing the Table +interface and GTK list tables. A uniquely selected row can restore its showing, +focused cell after a menu closes. Multiple selected rows must not produce a +guessed focus target. GUI full-row speech omits cells that are not showing. +When Qt hides a submenu without a focus event, its parent action is accepted +only with fresh provider focus and visibility evidence. + +Run `python3 tests/native_audacious.py` with Audacious's Qt and GTK interfaces, +Xvfb, i3, xdotool, Python gi/pyatspi, and the native build dependencies installed. +The harness creates temporary tracks and private configuration, display and +D-Bus sessions. It checks queued speech and Braille for playlists, nested menus, +the playlist manager, settings, and context menus. It does not verify audible +speech or a physical Braille display. Also run the +[generic Qt checks](qt-control-states.md). + +For live acceptance: + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. In an Audacious playlist, move between tracks. Expect the selected track's + visible row content in speech and the focused cell on Braille, without + hidden filename/path columns. +3. Open Playlist's Sort submenu. Move through commands, then press Left and + Escape. Expect the parent menu item on return and the original track when + the menus close; arrow keys must continue moving through tracks. +4. Open Settings, Tab and Shift+Tab through controls, then close it. Expect each + focused control and restoration of the playlist. Unfocused password-field + initialization must not be announced. +5. Repeat with the other interface when available. Record the exact speech, + Braille, focused control and key sequence for any discrepancy. diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index 5317b44..6d3ff6b 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -5,8 +5,10 @@ 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 provider first resolve their containing window: GTK socket +children can belong to the foreground host while using a different provider +bus. Unrelated providers are then rejected using the resolved window and copied +bus identities, before any content queries. 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. @@ -16,8 +18,9 @@ their existing handling. Announcement events are explicit requests to speak and also bypass the filter; see [announcements](announcements.md). Native alerts, notification daemons and supported Steam toasts retain their notification paths. Menu popups retain existing foreground checks. Verified active/showing `mate-panel` menu windows may pass -across provider boundaries, including over an empty desktop; ordinary panel -controls and hidden menus remain filtered. See [MATE desktop](mate-desktop.md). +across provider boundaries, including over an empty desktop. Embedded panel +controls use their host's normal X11 correlation; unrelated panel windows and +hidden menus remain filtered. See [MATE desktop](mate-desktop.md). 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. diff --git a/docs/testing/mate-desktop.md b/docs/testing/mate-desktop.md index 454cc86..98b32d6 100644 --- a/docs/testing/mate-desktop.md +++ b/docs/testing/mate-desktop.md @@ -18,6 +18,7 @@ Reviewed the local `orca/` checkout at revision `a8a851800`: | Marco window cycling | `script_manager.py`, `scripts/switcher/script.py`, `scripts/switcher/script_utilities.py` | Already implemented; see [window switcher checks](window-switcher.md). | | MATE notification daemon | `script_manager.py`, `scripts/apps/notification-daemon/script.py` | Already routed through notification capture, speech, Braille and history; added an explicit MATE daemon regression. | | Unnamed panel icons | `generator.py:_generate_accessible_name` | Adapted the containing panel's name/description when the icon supplies neither. Own names and descriptions retain precedence. | +| Embedded panel applets | `script_utilities.py:frame_and_dialog`, `script_utilities.py:top_level_object`, `scripts/default.py:_on_focused_changed` | Follow the accessible parent chain through a GTK plug/socket to its host window before normal foreground validation. | Orca has no dedicated `mate-panel` script in this checkout. Its current app script catalog also contains no Caja, Pluma, Atril, Engrampa or Eye of MATE @@ -39,10 +40,32 @@ hidden menu items retain normal filtering. Native actions and review use the same window validation. The correlated X11 application is preserved; no popup cache, native references, timer or extra window owner is introduced. +## Embedded applet ownership + +The newer `kendell/mate.log`, recorded with revision `702894ee`, shows a +different failure: Bottom Panel controls are accepted, but focused icons from +other provider buses are rejected as outside the X11 foreground. The reader +announces the host's generic "panel" while discarding the embedded icon. + +A real GTK socket/plug reproduction exposes the same structure. The applet +publishes its own frame, whose accessible parent chain crosses the socket's +provider and reaches the panel frame. Window resolution now follows that chain +to the host instead of stopping at the plug frame. The background filter also +resolves this window before rejecting a different provider bus. The source +object retains its own bus, path and process identity for focus and actions. + +This is shared window ancestry handling: it requires no MATE process-name +exception. Normal X11 process, state and geometry checks still apply to the +host. Same-provider nested frames retain their existing nearest-window +behavior. An unembedded or detached applet cannot borrow the host's identity, +and a background host cannot admit applet focus. Parent links beyond the plug +frame are refreshed on the GLib owner; no embedding cache is retained. + ## Automated checks ```sh python3 tests/native_test_session.py env CTHULHU_NATIVE_PANEL_MENU=1 CTHULHU_NATIVE_WINDOW_SWITCHER=1 CTHULHU_NATIVE_BACKGROUND_EVENTS=1 go test -race ./internal/atspi ./internal/app -run '^TestNative(MatePanelMenu|MateWindowSwitcherOutsideForeground|BackgroundEventFiltering)' -count=1 -p=1 -timeout=90s -v +python3 tests/native_test_session.py env CTHULHU_NATIVE_PANEL_APPLET=1 go test -race ./internal/atspi ./internal/app -run '^TestNativeEmbeddedPanelApplet' -count=1 -p=1 -timeout=90s -v go test ./internal/app -run '^TestNotificationProvidersAndHistoryMatchReferenceBehavior$' -count=1 ``` @@ -55,6 +78,14 @@ inherited labels, own-name/description precedence and unrelated-container rejection. The event-loop check verifies queued speech and Braille frames for navigation, dismissal, editor return and reopening. It emits no audible speech. +The socket/plug fixture uses separate host, applet and editor processes. It +checks the shared containing-window identity, preserved applet identity, icon +label inheritance, Tab/Shift+Tab within the applet, host controls on either +side, and editor restoration. It also checks a non-MATE host, background applet +focus, an unrelated applet claiming the MATE application name, and stale focus +after socket destruction. The event-loop check verifies speech requests and +Braille frames through two panel/editor round trips. + The existing background flood regression checks that ordinary same-provider and other-provider updates still cause zero content queries. The Marco regression checks switcher insertions and showing events while an editor @@ -87,6 +118,9 @@ MATE session. After starting the candidate through the normal launcher: typing and ordinary Tab navigation. Check terminal-reader handoff if used. 3. Use Control+Alt+Tab to reach the panel and navigate its applets. Confirm applet icons use supplied labels without repeating unrelated container text. + Traverse both directions through launchers, clock, notification area and + configured external applets. Open and dismiss applet controls, then return + to the previous application. Background applet updates must stay silent. 4. Cycle windows with Alt+Tab and Shift+Alt+Tab, then cancel with Escape. Check a MATE notification and its notification-history entry. 5. Return to browser heading navigation and address-bar suggestions, and open diff --git a/docs/testing/messaging.md b/docs/testing/messaging.md index cdcf481..643eb32 100644 --- a/docs/testing/messaging.md +++ b/docs/testing/messaging.md @@ -128,3 +128,36 @@ The implementation work does not replace the running reader. once and the normal focus display returns afterward. Actual Pidgin listening, physical shortcuts, and Braille acceptance remain pending. + +## Discord message focus + +Named article ancestors are announced when focus enters their controls, matching +Python's article ancestor presentation. Discord supplies the message author, +contents, and timestamp in the article's accessible name. The message is spoken +once on entry, followed by the focused button. Moving between controls within +the same article does not repeat it. Leaving does not repeat the old message. +This also works in focus mode and while browser context discovery is pending. +Unnamed articles do not add announcements; other container preferences retain +their existing browse-mode policy. Native traversal retains its depth and time +limits and does not scan message descendants. + +Automated checks: + +```sh +go test ./internal/app -run '^TestMessageArticleAnnouncedOnceWhenTabbingIntoControls$' -count=1 +CTHULHU_NATIVE_BROWSER_TESTS=article python3 tests/native_browser.py +``` + +Both tests fail without the corresponding fix. The private Chromium fixture +checks native capture of a message article labelled by author, body, and time. +The speech replay checks entry, movement within a message, movement to another +message, exit, and re-entry. Neither establishes audible Discord acceptance. + +Before submission, review the diff and test a rebuilt reader in Discord with +Tab and Shift+Tab: enter a message's controls, move through Reply/Forward and +reactions, then enter another message. Confirm each message is read on entry, +control labels remain correct, and message contents do not repeat at each Tab +stop. Repeat in browse and focus modes; check the message composer, ordinary +browser buttons, and list/panel context announcements for regressions. Verify +physical Braille focus and routing separately. Installed-binary, physical-input, +audible-speech, and Braille-display acceptance remain pending. diff --git a/docs/testing/qt-control-states.md b/docs/testing/qt-control-states.md index a0a8a25..f93b130 100644 --- a/docs/testing/qt-control-states.md +++ b/docs/testing/qt-control-states.md @@ -40,8 +40,10 @@ queued speech and Braille frames through Cthulhu's event loop for menu-bar and context-menu actions, checking and unchecking ordinary checkboxes and checkable buttons, an ordinary menu command, same-label action replacement, and explicit capability removal. It also traverses shared and context-only actions in both -directions and after reopening the context menu. It requires both Python Qt bindings, -xdotool and the native build dependencies. It does not replace the active +directions and after reopening the context menu. It also checks Left and Escape +from nested menus, including providers which report the return only by hiding +the submenu; see [Audacious GUI checks](audacious.md). It requires both Python +Qt bindings, xdotool and the native build dependencies. It does not replace the active reader or establish audible speech or physical Braille acceptance. Existing package checks cover mixed states, state sounds, speech-only and diff --git a/docs/testing/tentacle-updates.md b/docs/testing/tentacle-updates.md new file mode 100644 index 0000000..1327469 --- /dev/null +++ b/docs/testing/tentacle-updates.md @@ -0,0 +1,44 @@ +# Tentacle update verification + +Refresh an installed Tentacle's repository in Preferences, select the Tentacle, +and choose Update. The confirmation displays both versions and the old and new +capabilities. Cancel must leave the installation unchanged. Update preserves +enablement at the point the package is saved, including a disable requested +while downloading. + +Versions use dotted numeric components, optional prerelease identifiers and +optional build metadata. Missing numeric components compare as zero; build +metadata does not make a release newer. Invalid versions are not offered as +updates. Downloads retain the existing HTTPS, checksum and manifest checks. + +An enabled replacement must register and activate before success is reported. +Startup failure or cancellation attempts to restore the previous package and +enablement. Recovery persistence errors are reported explicitly. Retired files +remain until their session has stopped. A disabled package is replaced without +starting it. + +Automated checks: + +```sh +go test ./internal/tentacle ./internal/app -run 'Test.*(Upgrade|Update|Version)' -count=1 +go build -o /tmp/cthulhu-update-check ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-update-check CTHULHU_PREFS_TENTACLE_UPDATES_ONLY=1 python3 tests/native_preferences.py +``` + +The GTK harness uses fixture data and a private display/bus, checks Tab and +Shift+Tab, confirmation details, Cancel and the upgrade request, and does not +update installed packages. Lifecycle tests exercise activation and rollback. + +For live acceptance: + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. Open Preferences and select an installed test Tentacle with a newer release. + Refresh its repository, then Tab to Update and use Shift+Tab to return. + Expect accessible control names in speech/Braille and no keyboard trap. +3. Activate Update. Verify versions and capabilities are readable, then Cancel. + Verify the installed version and existing shortcuts still work. +4. If you intend to install that release, reopen Update and accept. Verify the + new version, its shortcuts and its previous enabled/disabled state. Close + and reopen Preferences to verify persistence. +5. Record exact speech, Braille, focus and keyboard results for discrepancies. diff --git a/docs/testing/wine-proton.md b/docs/testing/wine-proton.md index 4790a5d..e1b6d4b 100644 --- a/docs/testing/wine-proton.md +++ b/docs/testing/wine-proton.md @@ -40,6 +40,32 @@ operational only; this contract remains normative if they differ. session singleton defeats any newly launched helper regardless of settings. +## Loader selection and helper staging + +Merged in from the testing branch's parallel document during the +2026-11-02 sync, corrected where Phases 1–3 superseded it. +Cthulhu selects a loader from the application's Wine environment +and executable. It bypasses a recognized Protontricks runtime +proxy and searches the Proton installation for a usable loader. +For processes advertising `PRESSURE_VESSEL_RUNTIME`, it stages the +complete helper set under +`$XDG_STATE_HOME/cthulhu/wine-access/helper` (default +`~/.local/state`). Sets are content-addressed and verified before +use; running sets are never overwritten. The helper keeps the +reader's D-Bus address and receives the application's display, +authentication, and Wine synchronization settings. Loader +selection, staging, and environment forwarding are covered by +`go test ./internal/wine ./internal/config -count=1`; fixtures do +not establish compatibility with a real Proton runtime or audible +Wine output. + +Two statements in that parallel document described pre-stage +behaviour and are **not** carried forward: Wine settings no longer +require a reader restart (a global preferences Apply republishes +them live, Phase 2/3), and an excluded program appearing in a +prefix no longer switches that prefix's helper wholesale to +controller-only mode (gating is per-program native, Phase 1). + ## Settled decisions 1. Scope of the next stage (2026-11-01): the manager trio (diagnostics diff --git a/docs/testing/xterm-handoff.md b/docs/testing/xterm-handoff.md new file mode 100644 index 0000000..4759b94 --- /dev/null +++ b/docs/testing/xterm-handoff.md @@ -0,0 +1,30 @@ +# XTerm handoff verification + +While input is delegated to a terminal reader in XTerm, Cthulhu suppresses +ordinary key echo, input-driven speech interruption and lock-state feedback. +It releases its command grabs. On return, Caps Lock tracking adopts the current +XKB lock state before treating Caps Lock as the reader modifier again. + +Focused checks: + +```sh +go test ./internal/app -run 'Test(XTerm|DelegatedInput)' -count=1 +python3 tests/native_x11.py +``` + +The native harness uses a private X display and checks grab suspension, +restoration and lock state with synthetic input. Audible output from another +reader and physical keyboard handling require live verification. + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. Focus XTerm with your console reader active. Press its reader modifier and + navigation keys. Expect the console reader to receive them, without + Cthulhu modifier echo or Cthulhu interrupting its speech. +3. Change Caps Lock in XTerm, then switch to an ordinary desktop application. + Expect Cthulhu to resume focused-control speech and Braille and accept its + configured modifier commands with the current lock state. +4. Switch back and repeat with keypad commands where configured. Confirm + focus stays on the intended application and no key remains grabbed after + handoff. Restore the original lock state. +5. Record the exact keys, focused application, speech and Braille for failures. diff --git a/internal/app/audacious_native_test.go b/internal/app/audacious_native_test.go new file mode 100644 index 0000000..be38584 --- /dev/null +++ b/internal/app/audacious_native_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func TestNativeAudacious(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "" { + t.Skip("requires private Audacious GUI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)} + frames := make(chan braille.Frame, 256) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), + outputQueue{speech: queue, braille: frameRecorder{frames: frames}}, + adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + keys := func(chord string) { + t.Helper() + t.Logf("KEY %s", chord) + if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { + t.Fatalf("keys: %v %s", err, out) + } + } + wait := func(wantSpeech, wantBraille string) { + t.Helper() + spoken, displayed := wantSpeech == "", wantBraille == "" + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for { + select { + case request := <-queue.requests: + t.Logf("speech %s: %q", request.Cause, request.Text) + if request.Cause == atspi.CaretEvent && strings.Contains(request.Text, "password text") { + t.Fatalf("unfocused proxy password initialization was spoken: %q", request.Text) + } + if strings.Contains(request.Text, ".wav") || strings.Contains(request.Text, "/tmp/") { + t.Fatalf("hidden playlist columns were spoken: %q", request.Text) + } + spoken = spoken || strings.Contains(request.Text, wantSpeech) + case frame := <-frames: + t.Logf("Braille %q", frame.Text) + displayed = displayed || strings.Contains(frame.Text, wantBraille) + case <-timer.C: + if spoken && displayed { + return + } + out, _ := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_audacious.py", "--diagnose").CombinedOutput() + t.Logf("native selection: %s", out) + t.Fatalf("missing output speech=%q (%v) Braille=%q (%v)", wantSpeech, spoken, wantBraille, displayed) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if spoken && displayed { + timer.Reset(80 * time.Millisecond) + } + } + } + step := func(chord, speech, braille string) { + t.Helper() + keys(chord) + wait(speech, braille) + } + // Queueing fixtures never starts playback. Select a row explicitly before + // testing return from menus: an initially unselected table has no row. + step("alt+f", "Open Files ..., menu item", "Open Files ...") + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" { + step("Down", "Open Folder ..., menu item", "Open Folder ...") + step("Down", "Open URL ..., menu item", "Open URL ...") + step("Up", "Open Folder ..., menu item", "Open Folder ...") + } else { + step("Down", "Open URL ..., menu item", "Open URL ...") + step("Up", "Open Files ..., menu item", "Open Files ...") + } + step("Right", "Play, menu item", "Play") + for _, name := range []string{"Pause", "Stop", "Previous", "Next", "Previous Album", "Next Album", "Repeat"} { + step("Down", name+", ", name) + } + step("Return", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + step("Down", "Beta, Example Artist, Example Album, 0:01", "Beta table cell") + step("Up", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + step("alt+p", "Play, menu item", "Play") + for _, name := range []string{"Pause", "Stop", "Previous", "Next", "Previous Album", "Next Album"} { + step("Down", name+", ", name) + } + step("Down", "Repeat, check menu item, checked", " Repeat check menu item") + step("Return", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + step("alt+p", "Play, menu item", "Play") + step("Right", "Play/Resume, menu item", "Play/Resume") + step("Down", "Refresh, menu item", "Refresh") + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" { + step("Down", "Find ..., menu item", "Find ...") + } + step("Down", "Sort, menu", "Sort") + step("Right", "By Track Number, menu item", "By Track Number") + step("Down", "By Title, menu item", "By Title") + step("Up", "By Track Number, menu item", "By Track Number") + step("Left", "Sort, menu", "Sort") + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" { + step("Escape", "Playlist, menu item", "Playlist") + } + step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + step("ctrl+p", "Playlist Manager", "New Playlist table cell") + step("Tab", "New, button", "New button") + step("shift+Tab", "New Playlist, 3", "New Playlist table cell") + step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + step("alt+f", "Open Files ..., menu item", "Open Files ...") + fileCommands := []string{"Open URL ...", "Add Files ...", "Add URL ...", "Search Library", "About ...", "Settings ..."} + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" { + fileCommands = []string{"Open Folder ...", "Open URL ...", "Add Files ...", "Add Folder ...", "Add URL ...", "Search Library", "About ...", "Settings ..."} + } + for _, name := range fileCommands { + step("Down", name+", menu item", name) + } + if os.Getenv("CTHULHU_NATIVE_AUDACIOUS") == "-Q" { + step("Return", "Audacious Settings", "Restart in") + step("Tab", "Qt Interface, combo box", "Qt Interface combo box") + step("Tab", "Native, combo box", "Native combo box") + step("shift+Tab", "Qt Interface, combo box", "Qt Interface combo box") + } else { + step("Return", "Audacious Settings", "Appearance") + step("Tab", "Restart in Qt mode, button", "Restart in Qt mode button") + step("Tab", "GTK Interface, combo box", "GTK Interface combo box") + step("shift+Tab", "Restart in Qt mode, button", "Restart in Qt mode button") + } + step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") + // Qt opens this popup without an active item; Down chooses the first. + keys("Menu") + step("Down", "Song Info ..., menu item", "Song Info ...") + step("Down", "Queue/Unqueue, menu item", "Queue/Unqueue") + step("Up", "Song Info ..., menu item", "Song Info ...") + step("Escape", "Alpha, Example Artist, Example Album, 0:01", "Alpha table cell") +} diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index fa3f5e3..86ceb03 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -203,7 +203,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { activity = browser.RecentInput() } intent := activity.Intent - if event.Type == atspi.CaretEvent && event.Source.EditableRoot.Valid() && state.focusedID.Valid() && + if event.Type == atspi.CaretEvent && (event.Source.EditableRoot.Valid() || event.Source.Role == "password text") && state.focusedID.Valid() && !belongsToFocusedEditor(event.Source, state.focusedID) { logger.Debug("skipped caret outside focused editor", "role", event.Source.Role) return diff --git a/internal/app/event_password_caret_test.go b/internal/app/event_password_caret_test.go new file mode 100644 index 0000000..c7e9bfb --- /dev/null +++ b/internal/app/event_password_caret_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestUnfocusedPasswordCaretDuringSettingsCreation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} + speech := &fakeSpeechQueue{spoken: make(chan string, 8)} + done := make(chan struct{}) + go func() { + defer close(done) + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + }() + defer func() { cancel(); <-done }() + focus := func(path, name, role string) { + source := atspi.NewSnapshot(path, name, role, "", "") + source.BusName = ":1.42" + adapter.events <- atspi.NewEvent(atspi.FocusEvent, source) + } + focus("/restart", "Restart interface", "button") + password := atspi.NewSnapshot("/proxy-password", "", "password text", "", "") + password.BusName = ":1.42" + adapter.events <- atspi.NewEvent(atspi.CaretEvent, password) + focus("/interface", "Interface", "combo box") + // A real focus event still announces the password field. The following + // button is an event-loop barrier, so stray caret speech cannot pass later. + focus("/proxy-password", "Proxy password", "password text") + focus("/close", "Close", "button") + for _, want := range []string{"Restart interface, button", "Interface, combo box", "Proxy password, password text", "Close, button"} { + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-ctx.Done(): + t.Fatalf("missing speech %q", want) + } + } +} diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 2005e40..2c3af7b 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -220,8 +220,22 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine} } browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName + context := event.Source.SpeechContext + if !browseContext && isFocusGainEvent(event) { + // Named articles label a focused descendant even in focus mode (for + // example, a chat message's Reply button), including while browser + // context discovery is pending. Other container context retains its + // browse-mode policy. + context = nil + for _, ancestor := range event.Source.SpeechContext { + if ancestor.Role == "article" && ancestor.Name != "" { + context = append(context, ancestor) + } + } + browseContext = len(context) != 0 + } speech.Enqueue(speechRequest{ - Context: event.Source.SpeechContext, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext), + Context: context, 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/panel_applet_native_test.go b/internal/app/panel_applet_native_test.go new file mode 100644 index 0000000..8031980 --- /dev/null +++ b/internal/app/panel_applet_native_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" +) + +func TestNativeEmbeddedPanelAppletPresentation(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + start := func(args ...string) (io.WriteCloser, string, string) { + t.Helper() + command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...) + 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() }) + line, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 3 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return input, fields[1], fields[2] + } + host, xid, socketID := start("panel") + start("applet", socketID) + editor, editorXID, _ := start("editor") + key := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool: %s, %v", output, err) + } + } + speech := &fakeSpeechQueue{spoken: make(chan string, 128)} + frames := make(chan braille.Frame, 128) + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), + outputQueue{speech: speech, braille: browserBrailleProbe{frames: frames}}, + adapter, nil, nil, 0, nil, nil, nil, nil) + }() + defer func() { cancel(); <-done }() + wait := func(name string) { + t.Helper() + spoken, displayed := false, false + var messages []string + for !spoken || !displayed { + select { + case text := <-speech.spoken: + messages = append(messages, text) + spoken = spoken || strings.Contains(text, name) + case frame := <-frames: + displayed = displayed || strings.Contains(frame.Text, name) + case <-ctx.Done(): + t.Fatalf("missing presentation %q: speech=%v braille=%v messages=%q", name, spoken, displayed, messages) + } + } + } + for iteration := 0; iteration < 2; iteration++ { + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + wait("Before applet") + key("key", "Tab") + wait("Network applet") + key("key", "Tab") + wait("Applet settings") + key("key", "shift+Tab") + wait("Network applet") + key("key", "Tab") + wait("Applet settings") + key("key", "Tab") + wait("After applet") + for _, name := range []string{"Applet settings", "Network applet", "Before applet"} { + key("key", "shift+Tab") + wait(name) + } + key("windowfocus", "--sync", editorXID) + text := fmt.Sprintf("Returned to editor %d", iteration) + fmt.Fprintln(editor, "edit "+text) + wait(text) + } +} diff --git a/internal/app/qt_controls_native_test.go b/internal/app/qt_controls_native_test.go index a7ed8b9..7fc0c24 100644 --- a/internal/app/qt_controls_native_test.go +++ b/internal/app/qt_controls_native_test.go @@ -173,6 +173,35 @@ func TestNativeQtControlStates(t *testing.T) { } keys("Escape") } + // Qt can report only SHOWING=false for a closing submenu, even though + // its parent action regains FOCUSED state. Restore that actual parent. + for _, closeKey := range []string{"Left", "Escape"} { + keys("F7") + waitEvent("Ordinary action", atspi.FocusedStateEvent, false) + waitSpeech("Ordinary action, menu item") + keys("Down") + waitEvent("Nested actions", atspi.FocusedStateEvent, false) + waitSpeech("Nested actions, menu item") + keys("Right") + waitEvent("Nested command", atspi.FocusedStateEvent, false) + waitSpeech("Nested command, menu item") + keys(closeKey) + // The normalized event preserves its native showing-event origin. + for { + select { + case event := <-observed: + if event.Source.Name == "Nested actions" && isFocusGainEvent(event) { + goto returned + } + case <-ctx.Done(): + t.Fatal("missing focus return from submenu") + } + } + returned: + waitSpeech("Nested actions, menu item") + waitBraille("Nested actions") + keys("Escape") + } if version == "6" { // When the provider exports CHECKABLE, withdrawing that capability is // authoritative and must retire the prior evidence. diff --git a/internal/app/reading_context.go b/internal/app/reading_context.go index a35145a..6f7f650 100644 --- a/internal/app/reading_context.go +++ b/internal/app/reading_context.go @@ -59,7 +59,9 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say enabled = presentation.SayAllContextEnabled } for _, item := range previous { - if !contains(current, item) && enabled(presentation.ContextType(item), settings) { + // Article labels can contain an entire chat message. Python announces + // them on entry only; repeating the label on exit obscures the next one. + if item.Role != "article" && !contains(current, item) && enabled(presentation.ContextType(item), settings) { appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".") } } diff --git a/internal/app/reading_context_test.go b/internal/app/reading_context_test.go index 88a427b..3fcd995 100644 --- a/internal/app/reading_context_test.go +++ b/internal/app/reading_context_test.go @@ -94,3 +94,41 @@ func TestReviewContextSuppressedOutsideBrowseMode(t *testing.T) { } } } + +func TestMessageArticleAnnouncedOnceWhenTabbingIntoControls(t *testing.T) { + for name, mode := range map[string]review.DocumentMode{"browse": review.BrowseMode, "focus": review.FocusMode, "resolving": review.InactiveMode} { + t.Run(name, func(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{ready: mode != review.InactiveMode, mode: mode, document: atspi.NewObjectID(":1.42", "/document")} + state := &eventLoopState{} + state.presentationOptions = presentation.DefaultOptions() + presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser} + article := atspi.Snapshot{BusName: ":1.42", Path: "/message1", Role: "article", Name: "Author: First message"} + check := func(path, name string, ancestors []atspi.Snapshot, want []string) { + t.Helper() + control := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "button", Name: name, SpeechContext: ancestors} + presenter.speak(atspi.NewEvent(atspi.FocusEvent, control)) + 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("/reply1", "Reply", []atspi.Snapshot{article}, []string{"Author: First message article", "Reply", "button"}) + check("/forward1", "Forward", []atspi.Snapshot{article}, []string{"Forward", "button"}) + article.Path, article.Name = "/message2", "Author: Second message" + check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"}) + check("/outside", "Outside", []atspi.Snapshot{}, []string{"Outside", "button"}) + check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"}) + }) + } +} diff --git a/internal/app/tentacle_lifecycle.go b/internal/app/tentacle_lifecycle.go new file mode 100644 index 0000000..6bd7a4b --- /dev/null +++ b/internal/app/tentacle_lifecycle.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +// A startup request is owned by the lifecycle worker until activation or failure. +// Cancellation only wakes that worker; it never mutates registry or session state. +type tentacleStartupCall struct { + call tentacleManagementCall + saveError error + stop func() bool + previous *tentacle.Entry +} + +// A retired package stays on disk until the session using it has joined. +type tentacleRetiredPackage struct { + entry tentacle.Entry + session *tentacle.Session +} + +// tentacleLifecycle owns managed state for one run, on the lifecycle goroutine. +// Watchers and cancellation callbacks only notify it; they never mutate these +// maps or transitions. Startup rollback runs before shutdown joins sessions. +type tentacleLifecycle struct { + ctx context.Context + runtime *tentacleRuntime + entries []tentacle.Entry + repositories []tentacle.Repository + loadError error + pending map[string]bool + sessions map[string]*tentacle.Session + problems map[string]string + startups map[string]tentacleStartupCall + removedPackages map[string]tentacleRetiredPackage + wake chan struct{} + manager *tentacle.Manager + current *tentacle.Session + ready <-chan struct{} + active []*tentacle.Session + watchers []<-chan struct{} +} + +func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { + entries, loadError := runtime.registry.Load() + owner := &tentacleLifecycle{ + ctx: ctx, + runtime: runtime, + entries: entries, + loadError: loadError, + repositories: runtime.registry.Repositories(), + pending: make(map[string]bool), + sessions: make(map[string]*tentacle.Session), + problems: make(map[string]string), + startups: make(map[string]tentacleStartupCall), + removedPackages: make(map[string]tentacleRetiredPackage), + wake: make(chan struct{}, 1), + } + if loadError == nil { + if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil { + runtime.logger.Warn("recover tentacle packages", "error", err) + } + } + for _, entry := range entries { + owner.pending[entry.ID] = entry.Enabled + } + owner.setSnapshot() + if loadError != nil { + runtime.report("Tentacles could not start.") + } + defer owner.shutdown() + defer owner.cancelStartups() + return owner.run() +} + +func (owner *tentacleLifecycle) notify() { + select { + case owner.wake <- struct{}{}: + default: + } +} + +func (owner *tentacleLifecycle) setSnapshot() { + result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(owner.repositories)} + if owner.loadError != nil { + result.Message = "Tentacle registry could not be loaded; no tentacles were started." + } + for _, entry := range owner.entries { + status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: owner.problems[entry.ID]} + if entry.Enabled { + status.State = "starting" + } + if session := owner.sessions[entry.ID]; session != nil { + state, _ := session.Status() + status.State = string(state) + if state == tentacle.Ready { + status.State = "starting" + } + if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" { + status.Message = "Tentacle stopped; retry to start it again." + } + } + if owner.pending[entry.ID] { + status.State = "starting" + } + if status.Message != "" && !owner.pending[entry.ID] { + status.State = "failed" + } + if !entry.Enabled && owner.sessions[entry.ID] == nil { + status.State = "disabled" + } + result.Entries = append(result.Entries, status) + } + result.Updates = tentacle.AvailableUpdates(result) + owner.runtime.mutex.Lock() + owner.runtime.snapshot = result + owner.runtime.mutex.Unlock() +} + +func (owner *tentacleLifecycle) shutdown() { + owner.runtime.Disable() + cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + var bindingError error + if owner.runtime.input != nil { + bindingError = owner.runtime.input.SetTentacleBindings(cleanup, nil) + } + if owner.runtime.readerContext.Err() != nil { + bindingError = nil + } + var managerError error + if owner.manager != nil { + managerError = owner.manager.Shutdown(cleanup) + } + for _, retired := range owner.removedPackages { + entry := retired.entry + if managerError == nil { + if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil { + owner.runtime.logger.Warn("remove tentacle package", "error", err) + } + } + } + for _, done := range owner.watchers { + <-done + } + owner.runtime.results.Wait() + owner.runtime.stopError = errors.Join(bindingError, managerError) + if owner.runtime.stopError != nil && owner.runtime.readerContext.Err() == nil { + select { + case owner.runtime.failures <- owner.runtime.stopError: + default: + } + } +} + +func (owner *tentacleLifecycle) reconcileBindings() error { + retained := owner.active[:0] + for _, session := range owner.active { + if state, _ := session.Status(); state == tentacle.Active { + retained = append(retained, session) + } + } + changed := len(retained) != len(owner.active) + owner.active = retained + if changed { + owner.runtime.publishCommands(owner.active) + if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil { + select { + case owner.runtime.failures <- err: + default: + } + return err + } + } + + return nil +} + +func (owner *tentacleLifecycle) reapSessions() { + for id, session := range owner.sessions { + select { + case <-session.Done(): + if state, failure := session.Status(); state == tentacle.Failed { + owner.runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure) + } + if !owner.pending[id] { + index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if index >= 0 && owner.entries[index].Enabled && owner.problems[id] == "" { + owner.problems[id] = "Tentacle stopped; retry to start it again." + owner.runtime.report("A tentacle stopped unexpectedly.") + } + } + delete(owner.sessions, id) + owner.runtime.mutex.Lock() + owner.runtime.sessions = slices.DeleteFunc(owner.runtime.sessions, func(value *tentacle.Session) bool { return value == session }) + owner.runtime.mutex.Unlock() + default: + } + } +} + +func (owner *tentacleLifecycle) removeRetiredPackages() { + for directory, retired := range owner.removedPackages { + entry := retired.entry + if retired.session != nil { + select { + case <-retired.session.Done(): + default: + continue + } + } + if slices.ContainsFunc(owner.entries, func(candidateEntry tentacle.Entry) bool { + return candidateEntry.PackageDirectory == directory + }) { + continue + } + if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil { + owner.runtime.logger.Warn("remove tentacle package", "error", err) + owner.runtime.report("Tentacle was removed, but its package files could not be deleted.") + continue + } + delete(owner.removedPackages, directory) + } +} + +func (owner *tentacleLifecycle) run() error { + for { + if owner.ctx.Err() != nil { + return nil + } + for id, startup := range owner.startups { + if startup.call.ctx.Err() != nil { + owner.failStartup(id, "Tentacle startup was cancelled before activation.") + } + } + // Reconcile revoked sessions before staging new native grabs, and release + // joined sessions before starting replacements or removing their files. + if err := owner.reconcileBindings(); err != nil { + return err + } + owner.reapSessions() + if owner.current == nil { + owner.removeRetiredPackages() + owner.startPending() + } + owner.setSnapshot() + select { + case <-owner.ctx.Done(): + return nil + case <-owner.wake: + case call := <-owner.runtime.requests: + owner.handleRequest(call) + case <-owner.ready: + if err := owner.activateReady(); err != nil { + return err + } + } + } +} diff --git a/internal/app/tentacle_lifecycle_requests.go b/internal/app/tentacle_lifecycle_requests.go new file mode 100644 index 0000000..5240087 --- /dev/null +++ b/internal/app/tentacle_lifecycle_requests.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "reflect" + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (owner *tentacleLifecycle) handleRequest(call tentacleManagementCall) { + if err := call.ctx.Err(); err != nil { + call.result <- err + return + } + if owner.loadError != nil { + call.result <- errors.New("tentacle registry could not be loaded; repair it and restart") + return + } + owner.runtime.mutex.Lock() + disabled := owner.runtime.disabled + owner.runtime.mutex.Unlock() + if disabled { + call.result <- tentacle.ErrDisabled + return + } + if strings.HasPrefix(call.request.Action, "repository-") { + next, err := change_tentacle_repositories(owner.repositories, call.request, call.repository) + if err == nil { + err = owner.runtime.registry.SaveState(owner.entries, next) + } + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + owner.repositories = next + owner.setSnapshot() + } + call.result <- err + return + } + if call.packageFiles != nil { + index := slices.IndexFunc(owner.repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL }) + if index < 0 || !reflect.DeepEqual(owner.repositories[index], *call.repository) { + call.result <- errors.New("Repository changed during installation; refresh and try again.") + return + } + } + if _, starting := owner.startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" { + call.result <- tentacle.ErrBusy + return + } + if startup, exists := owner.startups[call.request.ID]; exists && startup.previous != nil { + owner.failStartup(call.request.ID, "Tentacle update was cancelled by another operation.") + } + next, err := change_tentacle_entries(owner.entries, call.request) + if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, owner.entries) { + // Saved enablement is intent, not proof of a live session. An + // explicit enable/update must recover a failed or starting entry. + index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID }) + state := tentacle.Stopped + if session := owner.sessions[call.request.ID]; session != nil { + state, _ = session.Status() + } + if !next[index].Enabled || state == tentacle.Active { + call.result <- nil + return + } + } + if err == nil && call.request.Action != "retry" { + err = owner.runtime.registry.Save(next) + } + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + id := call.request.ID + var previous *tentacle.Entry + if call.packageFiles != nil && call.request.Action == "update" { + index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if index >= 0 && owner.entries[index].Enabled { + saved := tentacle.CloneEntries(owner.entries[index : index+1])[0] + previous = &saved + } + } + owner.replyStartup(id, errors.New("Tentacle startup was cancelled by another operation.")) + if call.packageFiles != nil { + call.packageFiles.Retain() + } + if call.request.Action == "remove" || call.request.Action == "update" { + for _, entry := range owner.entries { + if entry.ID != id || entry.PackageDirectory == "" { + continue + } + if call.request.Action == "update" && (previous != nil || + entry.PackageDirectory == call.request.Entry.PackageDirectory) { + continue + } + owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]} + } + } + owner.revoke(id) + if owner.current != nil && owner.current.TentacleID() == id { + owner.current, owner.ready = nil, nil + } + owner.entries = next + delete(owner.problems, id) + delete(owner.pending, id) + for _, entry := range owner.entries { + if entry.ID == id { + owner.pending[id] = entry.Enabled + } + } + // Immediately withdraw dispatch, without waiting for key release. + stillActive := slices.DeleteFunc(slices.Clone(owner.active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active }) + owner.runtime.publishCommands(stillActive) + owner.setSnapshot() + if owner.pending[id] { + owner.startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, owner.notify), previous: previous} + return + } + } + call.result <- err +} diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go index b5f23cf..65dd2ae 100644 --- a/internal/app/tentacle_management.go +++ b/internal/app/tentacle_management.go @@ -7,10 +7,7 @@ import ( "context" "errors" "fmt" - "reflect" "slices" - "strings" - "time" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/tentacle" @@ -24,21 +21,6 @@ type tentacleManagementCall struct { packageFiles *tentacle.PreparedPackage } -// A startup request is owned by the lifecycle worker until activation or failure. -// Cancellation only wakes that worker; it never mutates registry or session state. -type tentacleStartupCall struct { - call tentacleManagementCall - saveError error - stop func() bool - previous *tentacle.Entry -} - -// A retired package stays on disk until the session using it has joined. -type tentacleRetiredPackage struct { - entry tentacle.Entry - session *tentacle.Session -} - func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot { if runtime == nil { return tentacle.Snapshot{Suppressed: true, Message: "Tentacle management is unavailable for this run."} @@ -133,67 +115,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man return tentacle.ErrDisabled } if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" { - if request.Entry != nil { - return errors.New("unexpected tentacle entry in installation request") + repository, err := repositoryForInstallation(runtime.Snapshot(), request) + if err != nil { + return err } - snapshot := runtime.Snapshot() - if snapshot.Suppressed || snapshot.Message != "" { - return tentacle.ErrUnavailable - } - index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL }) - if index < 0 { - return errors.New("repository is not configured") - } - repository := snapshot.Repositories[index] - id := tentacle.PackageID(repository, request.ID) - installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) - if request.Action == "upgrade" && !installed { - return errors.New("tentacle is not installed") - } - if request.Action != "upgrade" && installed { - return errors.New("tentacle is already installed") - } - if request.Action == "upgrade" { - for _, status := range snapshot.Entries { - if status.Entry.ID != id { - continue - } - for _, manifest := range repository.Tentacles { - if manifest.ID != request.ID { - continue - } - if manifest.Version == status.Entry.Version { - return errors.New("tentacle is already at the repository version") - } - newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version) - if err != nil { - return fmt.Errorf("cannot compare tentacle versions: %w", err) - } - if !newer { - return errors.New("repository tentacle version is not newer than installed version") - } - break - } - break - } - } - runtime.mutex.Lock() - if runtime.repositoryBusy { - runtime.mutex.Unlock() + if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } - runtime.repositoryBusy = true - runtime.mutex.Unlock() - defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() - prepare := runtime.preparePackage - if prepare == nil { - prepare = tentacle.PreparePackage - } - prepareCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - stop := context.AfterFunc(runtime.lifecycleContext, cancel) - prepared, err := prepare(prepareCtx, tentacle.PackageRoot(runtime.registry.Path), repository, request.ID) - stop() - cancel() + defer runtime.endRepositoryWork() + prepared, err := runtime.prepareManagedPackage(ctx, repository, request.ID) if err != nil { return err } @@ -216,26 +146,12 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man if err != nil { return err } - runtime.mutex.Lock() - if runtime.repositoryBusy { - runtime.mutex.Unlock() + if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } - runtime.repositoryBusy = true - runtime.mutex.Unlock() - defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() + defer runtime.endRepositoryWork() request.URL, call.request.URL = origin, origin - fetch := runtime.fetchRepository - if fetch == nil { - fetch = tentacle.FetchRepository - } - // Fetch on the Preferences worker, leaving lifecycle and native input - // free to disable, revoke, and process failures while the network waits. - fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - stop := context.AfterFunc(runtime.lifecycleContext, cancel) - repository, err := fetch(fetchCtx, origin) - stop() - cancel() + repository, err := runtime.fetchManagedRepository(ctx, origin) if err != nil { return err } @@ -298,471 +214,3 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme } return next, tentacle.ValidateEntries(next) } - -func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { - entries, loadError := runtime.registry.Load() - repositories := runtime.registry.Repositories() - if loadError == nil { - if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil { - runtime.logger.Warn("recover tentacle packages", "error", err) - } - } - pending := make(map[string]bool) - sessions := make(map[string]*tentacle.Session) - problems := make(map[string]string) - startups := make(map[string]tentacleStartupCall) - removedPackages := make(map[string]tentacleRetiredPackage) - for _, entry := range entries { - pending[entry.ID] = entry.Enabled - } - wake := make(chan struct{}, 1) - notify := func() { - select { - case wake <- struct{}{}: - default: - } - } - var manager *tentacle.Manager - var current *tentacle.Session - var ready <-chan struct{} - var active []*tentacle.Session - var watchers []<-chan struct{} - setSnapshot := func() { - result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(repositories)} - if loadError != nil { - result.Message = "Tentacle registry could not be loaded; no tentacles were started." - } - for _, entry := range entries { - status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: problems[entry.ID]} - if entry.Enabled { - status.State = "starting" - } - if session := sessions[entry.ID]; session != nil { - state, _ := session.Status() - status.State = string(state) - if state == tentacle.Ready { - status.State = "starting" - } - if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" { - status.Message = "Tentacle stopped; retry to start it again." - } - } - if pending[entry.ID] { - status.State = "starting" - } - if status.Message != "" && !pending[entry.ID] { - status.State = "failed" - } - if !entry.Enabled && sessions[entry.ID] == nil { - status.State = "disabled" - } - result.Entries = append(result.Entries, status) - } - result.Updates = tentacle.AvailableUpdates(result) - runtime.mutex.Lock() - runtime.snapshot = result - runtime.mutex.Unlock() - } - setSnapshot() - if loadError != nil { - runtime.report("Tentacles could not start.") - } - defer func() { - runtime.Disable() - cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second) - defer cancel() - var bindingError error - if runtime.input != nil { - bindingError = runtime.input.SetTentacleBindings(cleanup, nil) - } - if runtime.readerContext.Err() != nil { - bindingError = nil - } - var managerError error - if manager != nil { - managerError = manager.Shutdown(cleanup) - } - for _, retired := range removedPackages { - entry := retired.entry - if managerError == nil { - if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil { - runtime.logger.Warn("remove tentacle package", "error", err) - } - } - } - for _, done := range watchers { - <-done - } - runtime.results.Wait() - runtime.stopError = errors.Join(bindingError, managerError) - if runtime.stopError != nil && runtime.readerContext.Err() == nil { - select { - case runtime.failures <- runtime.stopError: - default: - } - } - }() - ensureManager := func() error { - if manager != nil { - return nil - } - if runtime.input == nil { - return errors.New("native bindings are unavailable") - } - var err error - manager, err = runtime.makeManager() - return err - } - revoke := func(id string) { - if session := sessions[id]; session != nil { - session.Disable() - runtime.output.RetireRevoked() - } - } - replyStartup := func(id string, err error) { - startup, exists := startups[id] - if !exists { - return - } - delete(startups, id) - startup.stop() - if err == nil { - if startup.previous != nil { - previous := *startup.previous - // Starting the replacement already waited for the old session. - removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous} - } - err = startup.saveError - } - setSnapshot() - startup.call.result <- err - } - failStartup := func(id, message string) { - problems[id] = message - startup, exists := startups[id] - if !exists { - return - } - revoke(id) - if current != nil && current.TentacleID() == id { - current, ready = nil, nil - } - delete(pending, id) - next := tentacle.CloneEntries(entries) - for index := range next { - if next[index].ID == id { - if startup.previous != nil { - next[index] = *startup.previous - } else { - next[index].Enabled = false - } - } - } - err := runtime.registry.Save(next) - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - if startup.previous != nil { - failed := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if failed >= 0 { - entry := entries[failed] - removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]} - } - runtime.mutex.Lock() - disabled := runtime.disabled - runtime.mutex.Unlock() - pending[id] = startup.previous.Enabled && !disabled && ctx.Err() == nil - message += " The previous package was restored." - } - entries = next - } - if err != nil { - message += " The recovery state could not be saved durably: " + err.Error() - } - replyStartup(id, errors.New(message)) - } - defer func() { - for id := range startups { - failStartup(id, "Tentacle startup was cancelled before activation.") - } - }() - for { - if ctx.Err() != nil { - return nil - } - for id, startup := range startups { - if startup.call.ctx.Err() != nil { - failStartup(id, "Tentacle startup was cancelled before activation.") - } - } - // Reconcile revoked sessions before staging new native grabs. - retained := active[:0] - for _, session := range active { - if state, _ := session.Status(); state == tentacle.Active { - retained = append(retained, session) - } - } - changed := len(retained) != len(active) - active = retained - if changed { - runtime.publishCommands(active) - if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { - select { - case runtime.failures <- err: - default: - } - return err - } - } - // Joined sessions release host capacity before an explicit retry/update. - for id, session := range sessions { - select { - case <-session.Done(): - if state, failure := session.Status(); state == tentacle.Failed { - runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure) - } - if !pending[id] { - index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if index >= 0 && entries[index].Enabled && problems[id] == "" { - problems[id] = "Tentacle stopped; retry to start it again." - runtime.report("A tentacle stopped unexpectedly.") - } - } - delete(sessions, id) - runtime.mutex.Lock() - runtime.sessions = slices.DeleteFunc(runtime.sessions, func(value *tentacle.Session) bool { return value == session }) - runtime.mutex.Unlock() - default: - } - } - if current == nil { - for directory, retired := range removedPackages { - entry := retired.entry - if retired.session != nil { - select { - case <-retired.session.Done(): - default: - continue - } - } - if slices.ContainsFunc(entries, func(active tentacle.Entry) bool { - return active.PackageDirectory == directory - }) { - continue - } - if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil { - runtime.logger.Warn("remove tentacle package", "error", err) - runtime.report("Tentacle was removed, but its package files could not be deleted.") - continue - } - delete(removedPackages, directory) - } - for _, entry := range entries { - if !pending[entry.ID] || sessions[entry.ID] != nil { - continue - } - pending[entry.ID] = false - err := ensureManager() - var session *tentacle.Session - if err == nil { - session, err = manager.Start(ctx, entry.Spec()) - } - if err != nil { - failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.") - runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err) - runtime.report("A tentacle could not start.") - notify() - break - } - sessions[entry.ID] = session - runtime.mutex.Lock() - runtime.sessions = append(runtime.sessions, session) - if runtime.disabled { - session.Disable() - } - runtime.mutex.Unlock() - current, ready = session, session.Ready() - done := make(chan struct{}) - // Prune joined watcher handles so retries never accumulate history. - watchers = slices.DeleteFunc(watchers, func(done <-chan struct{}) bool { - select { - case <-done: - return true - default: - return false - } - }) - watchers = append(watchers, done) - go func() { - defer close(done) - select { - case <-session.Revoked(): - case <-ctx.Done(): - } - runtime.output.RetireRevoked() - runtime.signal_extensions() - notify() - <-session.Done() - notify() - }() - break - } - } - setSnapshot() - select { - case <-ctx.Done(): - return nil - case <-wake: - case call := <-runtime.requests: - if err := call.ctx.Err(); err != nil { - call.result <- err - continue - } - if loadError != nil { - call.result <- errors.New("tentacle registry could not be loaded; repair it and restart") - continue - } - runtime.mutex.Lock() - disabled := runtime.disabled - runtime.mutex.Unlock() - if disabled { - call.result <- tentacle.ErrDisabled - continue - } - if strings.HasPrefix(call.request.Action, "repository-") { - next, err := change_tentacle_repositories(repositories, call.request, call.repository) - if err == nil { - err = runtime.registry.SaveState(entries, next) - } - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - repositories = next - setSnapshot() - } - call.result <- err - continue - } - if call.packageFiles != nil { - index := slices.IndexFunc(repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL }) - if index < 0 || !reflect.DeepEqual(repositories[index], *call.repository) { - call.result <- errors.New("Repository changed during installation; refresh and try again.") - continue - } - } - if _, starting := startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" { - call.result <- tentacle.ErrBusy - continue - } - if startup, exists := startups[call.request.ID]; exists && startup.previous != nil { - failStartup(call.request.ID, "Tentacle update was cancelled by another operation.") - } - next, err := change_tentacle_entries(entries, call.request) - if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) { - // Saved enablement is intent, not proof of a live session. An - // explicit enable/update must recover a failed or starting entry. - index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID }) - state := tentacle.Stopped - if session := sessions[call.request.ID]; session != nil { - state, _ = session.Status() - } - if !next[index].Enabled || state == tentacle.Active { - call.result <- nil - continue - } - } - if err == nil && call.request.Action != "retry" { - err = runtime.registry.Save(next) - } - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - id := call.request.ID - var previous *tentacle.Entry - if call.packageFiles != nil && call.request.Action == "update" { - index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if index >= 0 && entries[index].Enabled { - saved := tentacle.CloneEntries(entries[index : index+1])[0] - previous = &saved - } - } - replyStartup(id, errors.New("Tentacle startup was cancelled by another operation.")) - if call.packageFiles != nil { - call.packageFiles.Retain() - } - if call.request.Action == "remove" || call.request.Action == "update" { - for _, entry := range entries { - if entry.ID != id || entry.PackageDirectory == "" { - continue - } - if call.request.Action == "update" && (previous != nil || - entry.PackageDirectory == call.request.Entry.PackageDirectory) { - continue - } - removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]} - } - } - revoke(id) - if current != nil && current.TentacleID() == id { - current, ready = nil, nil - } - entries = next - delete(problems, id) - delete(pending, id) - for _, entry := range entries { - if entry.ID == id { - pending[id] = entry.Enabled - } - } - // Immediately withdraw dispatch, without waiting for key release. - stillActive := slices.DeleteFunc(slices.Clone(active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active }) - runtime.publishCommands(stillActive) - setSnapshot() - if pending[id] { - startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous} - continue - } - } - call.result <- err - case <-ready: - session := current - current, ready = nil, nil - if state, _ := session.Status(); state != tentacle.Ready { - failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.") - continue - } - if startup, exists := startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil { - failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.") - continue - } - candidate := append(slices.Clone(active), session) - if err := runtime.bind(ctx, candidate); err != nil { - session.Disable() - runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err) - runtime.report("A tentacle could not acquire its keyboard shortcuts.") - failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.") - continue - } - // Native binding setup can wait for held keys. Cancellation during - // that wait must roll back the new grabs before reporting failure. - activationError := ctx.Err() - if startup, exists := startups[session.TentacleID()]; exists && activationError == nil { - activationError = startup.call.ctx.Err() - } - if activationError == nil { - activationError = session.Activate() - } - if activationError != nil { - session.Disable() - if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { - select { - case runtime.failures <- err: - default: - } - return err - } - failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.") - continue - } - active = candidate - delete(problems, session.TentacleID()) - runtime.publishCommands(active) - replyStartup(session.TentacleID(), nil) - runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) - } - } -} diff --git a/internal/app/tentacle_preparation.go b/internal/app/tentacle_preparation.go new file mode 100644 index 0000000..2241990 --- /dev/null +++ b/internal/app/tentacle_preparation.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +// Preparation runs on the caller's worker. Its busy reservation remains owned +// by Manage through the lifecycle reply, including rollback after cancellation. +func (runtime *tentacleRuntime) beginRepositoryWork() bool { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + if runtime.repositoryBusy { + return false + } + runtime.repositoryBusy = true + return true +} + +func (runtime *tentacleRuntime) endRepositoryWork() { + runtime.mutex.Lock() + runtime.repositoryBusy = false + runtime.mutex.Unlock() +} + +func repositoryForInstallation(snapshot tentacle.Snapshot, request tentacle.ManagementRequest) (tentacle.Repository, error) { + if request.Entry != nil { + return tentacle.Repository{}, errors.New("unexpected tentacle entry in installation request") + } + if snapshot.Suppressed || snapshot.Message != "" { + return tentacle.Repository{}, tentacle.ErrUnavailable + } + index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL }) + if index < 0 { + return tentacle.Repository{}, errors.New("repository is not configured") + } + repository := snapshot.Repositories[index] + id := tentacle.PackageID(repository, request.ID) + installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) + if request.Action == "upgrade" && !installed { + return tentacle.Repository{}, errors.New("tentacle is not installed") + } + if request.Action != "upgrade" && installed { + return tentacle.Repository{}, errors.New("tentacle is already installed") + } + if request.Action == "upgrade" { + for _, status := range snapshot.Entries { + if status.Entry.ID != id { + continue + } + for _, manifest := range repository.Tentacles { + if manifest.ID != request.ID { + continue + } + if manifest.Version == status.Entry.Version { + return tentacle.Repository{}, errors.New("tentacle is already at the repository version") + } + newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version) + if err != nil { + return tentacle.Repository{}, fmt.Errorf("cannot compare tentacle versions: %w", err) + } + if !newer { + return tentacle.Repository{}, errors.New("repository tentacle version is not newer than installed version") + } + break + } + break + } + } + return repository, nil +} + +func (runtime *tentacleRuntime) prepareManagedPackage(ctx context.Context, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) { + prepare := runtime.preparePackage + if prepare == nil { + prepare = tentacle.PreparePackage + } + prepareCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + stop := context.AfterFunc(runtime.lifecycleContext, cancel) + prepared, err := prepare(prepareCtx, tentacle.PackageRoot(runtime.registry.Path), repository, id) + stop() + cancel() + return prepared, err +} + +func (runtime *tentacleRuntime) fetchManagedRepository(ctx context.Context, origin string) (tentacle.Repository, error) { + fetch := runtime.fetchRepository + if fetch == nil { + fetch = tentacle.FetchRepository + } + // Fetch on the Preferences worker, leaving lifecycle and native input + // free to disable, revoke, and process failures while the network waits. + fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + stop := context.AfterFunc(runtime.lifecycleContext, cancel) + repository, err := fetch(fetchCtx, origin) + stop() + cancel() + return repository, err +} diff --git a/internal/app/tentacle_startup.go b/internal/app/tentacle_startup.go new file mode 100644 index 0000000..aa70726 --- /dev/null +++ b/internal/app/tentacle_startup.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "errors" + "slices" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (owner *tentacleLifecycle) ensureManager() error { + if owner.manager != nil { + return nil + } + if owner.runtime.input == nil { + return errors.New("native bindings are unavailable") + } + var err error + owner.manager, err = owner.runtime.makeManager() + return err +} + +func (owner *tentacleLifecycle) revoke(id string) { + if session := owner.sessions[id]; session != nil { + session.Disable() + owner.runtime.output.RetireRevoked() + } +} + +func (owner *tentacleLifecycle) replyStartup(id string, err error) { + startup, exists := owner.startups[id] + if !exists { + return + } + delete(owner.startups, id) + startup.stop() + if err == nil { + if startup.previous != nil { + previous := *startup.previous + // Starting the replacement already waited for the old session. + owner.removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous} + } + err = startup.saveError + } + owner.setSnapshot() + startup.call.result <- err +} + +func (owner *tentacleLifecycle) failStartup(id, message string) { + owner.problems[id] = message + startup, exists := owner.startups[id] + if !exists { + return + } + owner.revoke(id) + if owner.current != nil && owner.current.TentacleID() == id { + owner.current, owner.ready = nil, nil + } + delete(owner.pending, id) + next := tentacle.CloneEntries(owner.entries) + for index := range next { + if next[index].ID == id { + if startup.previous != nil { + next[index] = *startup.previous + } else { + next[index].Enabled = false + } + } + } + err := owner.runtime.registry.Save(next) + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + if startup.previous != nil { + failed := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if failed >= 0 { + entry := owner.entries[failed] + owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]} + } + owner.runtime.mutex.Lock() + disabled := owner.runtime.disabled + owner.runtime.mutex.Unlock() + owner.pending[id] = startup.previous.Enabled && !disabled && owner.ctx.Err() == nil + message += " The previous package was restored." + } + owner.entries = next + } + if err != nil { + message += " The recovery state could not be saved durably: " + err.Error() + } + owner.replyStartup(id, errors.New(message)) +} + +func (owner *tentacleLifecycle) cancelStartups() { + for id := range owner.startups { + owner.failStartup(id, "Tentacle startup was cancelled before activation.") + } +} + +func (owner *tentacleLifecycle) startPending() { + for _, entry := range owner.entries { + if !owner.pending[entry.ID] || owner.sessions[entry.ID] != nil { + continue + } + owner.pending[entry.ID] = false + err := owner.ensureManager() + var session *tentacle.Session + if err == nil { + session, err = owner.manager.Start(owner.ctx, entry.Spec()) + } + if err != nil { + owner.failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.") + owner.runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err) + owner.runtime.report("A tentacle could not start.") + owner.notify() + break + } + owner.sessions[entry.ID] = session + owner.runtime.mutex.Lock() + owner.runtime.sessions = append(owner.runtime.sessions, session) + if owner.runtime.disabled { + session.Disable() + } + owner.runtime.mutex.Unlock() + owner.current, owner.ready = session, session.Ready() + done := make(chan struct{}) + // Prune joined watcher handles so retries never accumulate history. + owner.watchers = slices.DeleteFunc(owner.watchers, func(done <-chan struct{}) bool { + select { + case <-done: + return true + default: + return false + } + }) + owner.watchers = append(owner.watchers, done) + go func() { + defer close(done) + select { + case <-session.Revoked(): + case <-owner.ctx.Done(): + } + owner.runtime.output.RetireRevoked() + owner.runtime.signal_extensions() + owner.notify() + <-session.Done() + owner.notify() + }() + break + } +} + +func (owner *tentacleLifecycle) activateReady() error { + session := owner.current + owner.current, owner.ready = nil, nil + if state, _ := session.Status(); state != tentacle.Ready { + owner.failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.") + return nil + } + if startup, exists := owner.startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil { + owner.failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.") + return nil + } + candidate := append(slices.Clone(owner.active), session) + if err := owner.runtime.bind(owner.ctx, candidate); err != nil { + session.Disable() + owner.runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err) + owner.runtime.report("A tentacle could not acquire its keyboard shortcuts.") + owner.failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.") + return nil + } + // Native binding setup can wait for held keys. Cancellation during + // that wait must roll back the new grabs before reporting failure. + activationError := owner.ctx.Err() + if startup, exists := owner.startups[session.TentacleID()]; exists && activationError == nil { + activationError = startup.call.ctx.Err() + } + if activationError == nil { + activationError = session.Activate() + } + if activationError != nil { + session.Disable() + if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil { + select { + case owner.runtime.failures <- err: + default: + } + return err + } + owner.failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.") + return nil + } + owner.active = candidate + delete(owner.problems, session.TentacleID()) + owner.runtime.publishCommands(owner.active) + owner.replyStartup(session.TentacleID(), nil) + owner.runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) + return nil +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 52df873..7993021 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -65,7 +65,7 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, AtspiAccessible *tableAccessible = NULL; gint row = -1; gint column = -1; - if (atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_TABLE) { + if (cthulhu_is_table_container(source)) { tableAccessible = g_object_ref(source); } else if (atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_TABLE_CELL) { AtspiTableCell *cell = atspi_accessible_get_table_cell(source); @@ -76,8 +76,7 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, } if (tableAccessible == NULL) { AtspiAccessible *parent = atspi_accessible_get_parent(source, NULL); - if (parent != NULL && (atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_TABLE || - cthulhu_is_gtk_list_table(parent))) { + if (parent != NULL && cthulhu_is_table_container(parent)) { tableAccessible = parent; gint index = atspi_accessible_get_index_in_parent(source, NULL); AtspiTable *parentTable = atspi_accessible_get_table_iface(parent); @@ -125,6 +124,21 @@ gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, for (gint column = 0; column < *columns; column++) { AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL); if (item == NULL) continue; + // Qt keeps hidden columns in the Table interface. As in Orca's + // get_showing_cells_in_same_row, GUI full-row reading includes + // only showing cells. Document/spreadsheet capture keeps its + // existing policy. Query fresh state after column visibility changes. + if (*guiTable) { + atspi_accessible_clear_cache_single(item); + AtspiStateSet *states = atspi_accessible_get_state_set(item); + gboolean showing = states != NULL && + atspi_state_set_contains(states, ATSPI_STATE_SHOWING); + if (states != NULL) g_object_unref(states); + if (!showing) { + g_object_unref(item); + continue; + } + } gchar *name = atspi_accessible_get_name(item, NULL); if ((name == NULL || *name == '\0') && remaining != 0 && atspi_accessible_get_role(item, NULL) == ATSPI_ROLE_TABLE_CELL) { diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index ef4356e..35d98c4 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -52,13 +52,13 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc // A table's first selected child is its active child only when the // selection is unique. Multi-selection changes use their own events. targetRole := C.atspi_accessible_get_role(target, nil) - listTable := targetRole == C.ATSPI_ROLE_LIST && C.cthulhu_is_gtk_list_table(target) != 0 + tableContainer := C.cthulhu_is_table_container(target) != 0 // GTK notebooks report container focus before the active tab when an // X11 key grab ends. Resolve that container to its selected tab, as // with menus, so ordinary duplicate-focus handling preserves review. - if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || targetRole == C.ATSPI_ROLE_TABLE || listTable { + if targetRole == C.ATSPI_ROLE_MENU || targetRole == C.ATSPI_ROLE_PAGE_TAB_LIST || tableContainer { var child *C.AtspiAccessible - if targetRole == C.ATSPI_ROLE_TABLE || listTable { + if tableContainer { child = C.cthulhu_table_selected_child(target) } else { child = C.cthulhu_selected_child(target) @@ -93,7 +93,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc // child list. Tab creation/removal makes that list stale. C.atspi_accessible_clear_cache_single(event.source) } - if C.atspi_accessible_get_role(event.source, nil) == C.ATSPI_ROLE_TABLE || C.cthulhu_is_gtk_list_table(event.source) != 0 { + if C.cthulhu_is_table_container(event.source) != 0 { target = C.cthulhu_table_selected_child(event.source) } else { target = C.cthulhu_selected_child(event.source) @@ -229,6 +229,22 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target * return snapshot, popupFor } +// nativeWindowDescription runs on the GLib owner for both toolkit events and +// foreground recovery. A table can acquire focus before its frame cache is ready. +func nativeWindowDescription(window *C.AtspiAccessible, eventType, role string) string { + description := C.atspi_accessible_get_description(window, nil) + if (description == nil || C.GoString(description) == "") && + (role == "table" || role == "table cell") && + (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent) { + C.g_free(C.gpointer(description)) + C.atspi_accessible_clear_cache_single(window) + description = C.atspi_accessible_get_description(window, nil) + } + result := sanitizeSnapshotField(C.GoString(description)) + C.g_free(C.gpointer(description)) + return result +} + // enrichNativeWindowEvent queries and releases the window on the GLib owner. // It applies foreground, tab/document, table, and prompt state in query order. func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, snapshot Snapshot, focusEvent *Event) { @@ -271,16 +287,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A } if !focusEvent.ForegroundMismatch && window != nil && (eventType == WindowActivatedEvent || snapshot.Role == "table" || snapshot.Role == "table cell") { - description := C.atspi_accessible_get_description(window, nil) - if (description == nil || C.GoString(description) == "") && - (snapshot.Role == "table" || snapshot.Role == "table cell") && - (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent) { - C.g_free(C.gpointer(description)) - C.atspi_accessible_clear_cache_single(window) - description = C.atspi_accessible_get_description(window, nil) - } - focusEvent.WindowDescription = sanitizeSnapshotField(C.GoString(description)) - C.g_free(C.gpointer(description)) + focusEvent.WindowDescription = nativeWindowDescription(window, eventType, snapshot.Role) } if !focusEvent.ForegroundMismatch && (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent || @@ -433,6 +440,13 @@ 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)) + // 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 + } if selection := adapter.window_switcher_event(event, eventType); selection != nil { adapter.enqueueSecureEvent(*selection, secureEpoch) return @@ -500,65 +514,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { return } snapshotStarted := time.Now() - target := adapter.libreOfficeFocusTarget(event, eventType) - ownedTarget := target != nil - if target == nil { - target, ownedTarget = selectNativeEventTarget(event, eventType) - } + target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedMenuItem) if target == nil { return } - if (eventType == FocusEvent || eventType == FocusedStateEvent) && - C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM { - // Qt menus can change their checked state without a state-change - // event. Reopening the menu must capture the current provider state. - C.atspi_accessible_clear_cache_single(target) - } - if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) && - C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB { - // Firefox can emit selected-state events from the previous tab; - // query the current state/name before using either event path. - C.atspi_accessible_clear_cache_single(target) - } - if eventType == WindowDeactivatedEvent { - // Match Python's key-grab churn check against fresh window state rather - // than an AT-SPI cache entry from before the deactivation event. - C.atspi_accessible_clear_cache_single(event.source) - } - payload := C.cthulhu_event_payload(event) - snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live) - if completeLive { - text := C.cthulhu_live_content(event.source) - snapshot.Text = sanitizeSnapshotField(C.GoString(text)) - snapshot.LiveAtomic = true - C.g_free(C.gpointer(payload)) - payload = text - } - // Shared Qt actions can report focus through their original, hidden menu - // even while another menu emits focus-gained for that very accessible. - // The explicit event establishes focus; the queried state may disagree. - // Do not transfer this evidence to a redirected descendant or a dead item. - if snapshot.ID() == originID && !snapshot.Defunct && - (eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) { - switch snapshot.Role { - case "menu item", "check menu item", "radio menu item": - snapshot.Focused = true - } - } - var inlineMenuID ObjectID - if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct && - (snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" || - snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") { - if menu := C.cthulhu_inline_menu_container(target); menu != nil { - inlineMenuID = nativeObjectID(menu) - C.g_object_unref(C.gpointer(menu)) - if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName { - // The event's detail is authoritative even when libatspi still - // caches the previous SELECTED state on the first visit. - eventType, snapshot.Selected = ActiveDescendantEvent, true - } - } - } + refreshNativeEventTarget(event, target, eventType) + snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive) + eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot) // Chromium can drop ACTIVE from its visible main frame while a tiny // same-process sibling claims it. On X11, the correlated foreground // window is the stronger ownership check performed below. Keep the @@ -568,7 +530,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent && eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup && C.cthulhu_in_inactive_window(event.source) != 0 { - C.g_free(C.gpointer(payload)) if ownedTarget { C.g_object_unref(C.gpointer(target)) } @@ -582,7 +543,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { focusEvent := NewEventWithPayload( eventType, snapshot, - C.GoString(payload), + payload, ) if eventType != nativeEventType { focusEvent.NativeType = nativeEventType @@ -625,7 +586,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if eventType == ActiveDescendantEvent && inlineMenuID.Valid() { focusEvent.OriginPath = inlineMenuID.Path } - C.g_free(C.gpointer(payload)) if ownedTarget { C.g_object_unref(C.gpointer(target)) } diff --git a/internal/atspi/adapter_event_filter_native.go b/internal/atspi/adapter_event_filter_native.go index 064d7f9..95b9b83 100644 --- a/internal/atspi/adapter_event_filter_native.go +++ b/internal/atspi/adapter_event_filter_native.go @@ -32,7 +32,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy 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) && + if foreground.Client.Window == 0 && adapter.applicationProcess(event.source) != "mate-panel" { return true } @@ -43,6 +43,14 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy return false } defer C.g_object_unref(C.gpointer(window)) + // An embedded GTK applet has its own provider bus but belongs to the + // socket host's window. Resolve that ancestry before the cross-bus fast + // rejection; the host still has to pass normal X11 foreground checks. + windowID := nativeObjectID(window) + if adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName && + windowID.BusName != adapter.foregroundWindow.BusName && adapter.applicationProcess(event.source) != "mate-panel" { + return true + } focusedMenuItem := false if eventType == FocusEvent || eventType == FocusedStateEvent && event.detail1 != 0 { switch C.atspi_accessible_get_role(event.source, nil) { @@ -50,7 +58,7 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy focusedMenuItem = true } } - if adapter.matchForeground(window, nativeObjectID(window), nil, focusedMenuItem) { + if adapter.matchForeground(window, windowID, nil, focusedMenuItem) { return false } if origin.BusName == adapter.foregroundWindow.BusName { diff --git a/internal/atspi/adapter_event_target_native.go b/internal/atspi/adapter_event_target_native.go new file mode 100644 index 0000000..e1aefcb --- /dev/null +++ b/internal/atspi/adapter_event_target_native.go @@ -0,0 +1,96 @@ +// 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" + +// resolveNativeEventTarget runs on the GLib owner. The returned menu item 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 + ownedTarget := false + if target == nil { + target = adapter.libreOfficeFocusTarget(event, eventType) + ownedTarget = target != nil + } + if target == nil { + target, ownedTarget = selectNativeEventTarget(event, eventType) + } + return target, ownedTarget +} + +// Refresh only the established provider caches, in query order, before capture. +// Both accessibles are borrowed and stay on the GLib owner thread. +func refreshNativeEventTarget(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string) { + if (eventType == FocusEvent || eventType == FocusedStateEvent) && + C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM { + // Qt menus can change their checked state without a state-change + // event. Reopening the menu must capture the current provider state. + C.atspi_accessible_clear_cache_single(target) + } + if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) && + C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB { + // Firefox can emit selected-state events from the previous tab; + // query the current state/name before using either event path. + C.atspi_accessible_clear_cache_single(target) + } + if eventType == WindowDeactivatedEvent { + // Match Python's key-grab churn check against fresh window state rather + // than an AT-SPI cache entry from before the deactivation event. + C.atspi_accessible_clear_cache_single(event.source) + } +} + +// captureNativeEventContent borrows event/target and owns all payload buffers. +// A completed atomic live region replaces the event payload before copying it. +// No native references escape this helper; all calls remain on the GLib owner. +func (adapter *Adapter) captureNativeEventContent(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, live nativeLiveProperties, completeLive bool) (Snapshot, ObjectID, string) { + payload := C.cthulhu_event_payload(event) + defer func() { C.g_free(C.gpointer(payload)) }() + snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live) + if completeLive { + text := C.cthulhu_live_content(event.source) + snapshot.Text = sanitizeSnapshotField(C.GoString(text)) + snapshot.LiveAtomic = true + C.g_free(C.gpointer(payload)) + payload = text + } + return snapshot, popupFor, C.GoString(payload) +} + +// normalizeNativeMenuFocus applies provider evidence to this event's snapshot. +// It borrows target and releases the temporary inline-menu reference locally. +func normalizeNativeMenuFocus(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, snapshot *Snapshot) (string, ObjectID) { + // Shared Qt actions can report focus through their original, hidden menu + // even while another menu emits focus-gained for that very accessible. + // The explicit event establishes focus; the queried state may disagree. + // Do not transfer this evidence to a redirected descendant or a dead item. + if snapshot.ID() == originID && !snapshot.Defunct && + (eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) { + switch snapshot.Role { + case "menu item", "check menu item", "radio menu item": + snapshot.Focused = true + } + } + var inlineMenuID ObjectID + if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct && + (snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" || + snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") { + if menu := C.cthulhu_inline_menu_container(target); menu != nil { + inlineMenuID = nativeObjectID(menu) + C.g_object_unref(C.gpointer(menu)) + if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName { + // The event's detail is authoritative even when libatspi still + // caches the previous SELECTED state on the first visit. + eventType, snapshot.Selected = ActiveDescendantEvent, true + } + } + } + return eventType, inlineMenuID +} diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 9e12577..e494791 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -157,20 +157,66 @@ gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source) { return matches; } +// Qt item views use TREE even for flat, multi-column playlists. Require the +// Table interface before applying row-selection policy to a tree. +gboolean cthulhu_is_table_container(AtspiAccessible *source) { + AtspiRole role = atspi_accessible_get_role(source, NULL); + if (role == ATSPI_ROLE_TABLE) return TRUE; + if (role == ATSPI_ROLE_LIST) return cthulhu_is_gtk_list_table(source); + if (role != ATSPI_ROLE_TREE && role != ATSPI_ROLE_TREE_TABLE) return FALSE; + AtspiTable *table = atspi_accessible_get_table_iface(source); + if (table == NULL) return FALSE; + g_object_unref(table); + return TRUE; +} + // Selection counts cells, so a single GTK row with two columns already has // two selected children. Query the table's selected rows before treating its // first selected cell as the focus target. AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source) { + // A menu/dialog can return focus without refreshing the cached child list. + atspi_accessible_clear_cache_single(source); AtspiTable *table = atspi_accessible_get_table_iface(source); if (table == NULL) return cthulhu_selected_child(source); GError *error = NULL; gint count = atspi_table_get_n_selected_rows(table, &error); - g_object_unref(table); gboolean ambiguous = error == NULL && count > 1; if (error != NULL) { - g_error_free(error); + g_clear_error(&error); } - return ambiguous ? NULL : cthulhu_selected_child(source); + AtspiAccessible *child = ambiguous ? NULL : cthulhu_selected_child(source); + // Qt selects every cell in a row, including an unnamed status column. + // Prefer the provider's focused cell in that same uniquely selected row + // so returning from a menu restores a useful Braille/review target too. + // Keep the existing target when no such evidence is available; never + // search other rows or guess a focus target in a multiple selection. + gint columns = atspi_table_get_n_columns(table, NULL); + if (!ambiguous && count == 1 && columns > 0 && columns <= 32) { + GArray *rows = atspi_table_get_selected_rows(table, NULL); + if (rows != NULL && rows->len == 1) { + gint row = g_array_index(rows, gint, 0); + for (gint column = 0; column < columns; column++) { + AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL); + if (item == NULL) continue; + atspi_accessible_clear_cache_single(item); + AtspiStateSet *states = atspi_accessible_get_state_set(item); + gboolean focused = states != NULL && + atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) && + atspi_state_set_contains(states, ATSPI_STATE_SHOWING) && + !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + if (states != NULL) g_object_unref(states); + if (focused) { + if (child != NULL) g_object_unref(child); + child = item; + break; + } + g_object_unref(item); + } + } + if (rows != NULL) g_array_unref(rows); + } + g_object_unref(table); + return child; } // Resolve the focused descendant at a window-activation boundary. Providers @@ -609,37 +655,68 @@ gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) { // Return the containing top-level window. An embedded web dialog is part of // its frame; a standalone dialog directly below the application owns itself. +// GTK plugs also publish a frame, but its parent chain crosses the socket's +// provider before reaching the host window. Keep that host as the owner while +// retaining the source object's provider identity. Same-provider nested frames +// retain their existing nearest-window behavior. AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) { if (source == NULL) return NULL; AtspiAccessible *current = g_object_ref(source); + AtspiAccessible *window = NULL; for (gint depth = 0; current != NULL && depth < 256; depth++) { GError *error = NULL; AtspiRole role = atspi_accessible_get_role(current, &error); if (error != NULL) { g_clear_error(&error); g_object_unref(current); + if (window != NULL) g_object_unref(window); return NULL; } - if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) { - return current; + if (role == ATSPI_ROLE_APPLICATION) { + g_object_unref(current); + return window; + } + if ((role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) && + (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window), + cthulhu_accessible_bus_name(current)) != 0)) { + if (window != NULL) g_object_unref(window); + window = g_object_ref(current); } AtspiAccessible *parent = atspi_accessible_get_parent(current, &error); + // Only a frame with parents below the application can be a plug. + // Refresh that chain to reject detached sockets, without invalidating + // every ordinary top-level window for every accessibility event. + if (window != NULL && parent != NULL && error == NULL && + atspi_accessible_get_role(parent, NULL) != ATSPI_ROLE_APPLICATION) { + g_object_unref(parent); + atspi_accessible_clear_cache_single(current); + parent = atspi_accessible_get_parent(current, &error); + } // Qt can expose a top-level QWidget as a filler rather than a frame. // Python's Qt utilities use the application-child boundary here. if (parent != NULL && error == NULL && role != ATSPI_ROLE_APPLICATION && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_APPLICATION) { g_object_unref(parent); - return current; + if (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window), + cthulhu_accessible_bus_name(current)) != 0) { + if (window != NULL) g_object_unref(window); + return current; + } + g_object_unref(current); + return window; } g_object_unref(current); if (error != NULL) { g_clear_error(&error); if (parent != NULL) g_object_unref(parent); + if (window != NULL) g_object_unref(window); return NULL; } current = parent; } if (current != NULL) g_object_unref(current); + if (current == NULL) return window; + if (window != NULL) g_object_unref(window); return NULL; } diff --git a/internal/atspi/adapter_foreground_native.go b/internal/atspi/adapter_foreground_native.go index 6e9623d..fdd92c6 100644 --- a/internal/atspi/adapter_foreground_native.go +++ b/internal/atspi/adapter_foreground_native.go @@ -341,6 +341,7 @@ func (adapter *Adapter) recoverForeground() { name := C.atspi_accessible_get_name(window, nil) event.WindowTitle = sanitizeSnapshotField(C.GoString(name)) C.g_free(C.gpointer(name)) + event.WindowDescription = nativeWindowDescription(window, event.Type, snapshot.Role) if snapshot.Role == "tree table" || snapshot.Role == "table cell" { if labels := C.cthulhu_collect_simple_prompt_text(window); labels != nil { event.WindowMessage = sanitizeSnapshotField(C.GoString(labels)) diff --git a/internal/atspi/adapter_menu_states.c b/internal/atspi/adapter_menu_states.c index daa3a20..536e16f 100644 --- a/internal/atspi/adapter_menu_states.c +++ b/internal/atspi/adapter_menu_states.c @@ -10,6 +10,33 @@ static const gchar *menu_checkability_key = "cthulhu-menu-checkability"; enum { CHECKABILITY_INFERRED = 1, CHECKABILITY_EXPLICIT = 2 }; +// Qt can hide a submenu on Left/Escape without emitting focus-gained for +// its parent action. Require fresh provider focus and visibility on that +// exact parent; closing an entire menu or switching away grants no focus. +AtspiAccessible *cthulhu_returned_menu_item(AtspiAccessible *menu) { + atspi_accessible_clear_cache_single(menu); + AtspiStateSet *states = atspi_accessible_get_state_set(menu); + gboolean hidden = states != NULL && + !atspi_state_set_contains(states, ATSPI_STATE_SHOWING) && + !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + if (states != NULL) g_object_unref(states); + if (!hidden) return NULL; + AtspiAccessible *parent = atspi_accessible_get_parent(menu, NULL); + if (parent == NULL) return NULL; + atspi_accessible_clear_cache_single(parent); + AtspiRole role = atspi_accessible_get_role(parent, NULL); + states = atspi_accessible_get_state_set(parent); + gboolean focused = states != NULL && + atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) && + atspi_state_set_contains(states, ATSPI_STATE_SHOWING) && + !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); + if (states != NULL) g_object_unref(states); + if (focused && (role == ATSPI_ROLE_MENU_ITEM || role == ATSPI_ROLE_CHECK_MENU_ITEM || + role == ATSPI_ROLE_RADIO_MENU_ITEM)) return parent; + g_object_unref(parent); + return NULL; +} + void cthulhu_forget_menu_checkability(AtspiAccessible *source) { if (source != NULL) g_object_set_data(G_OBJECT(source), menu_checkability_key, NULL); diff --git a/internal/atspi/adapter_menu_states_native.go b/internal/atspi/adapter_menu_states_native.go new file mode 100644 index 0000000..d0a3105 --- /dev/null +++ b/internal/atspi/adapter_menu_states_native.go @@ -0,0 +1,39 @@ +// 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" + +// A hiding submenu can return focus only from its most recently focused +// direct item. Queries and the returned reference belong to the GLib owner; +// the callback releases the reference after the ordinary foreground gates. +func (adapter *Adapter) returned_menu_item(event *C.AtspiEvent, eventType string, origin ObjectID) *C.AtspiAccessible { + if eventType != ShowingStateEvent || event.detail1 != 0 || len(adapter.nativeFocus) == 0 { + return nil + } + role := C.atspi_accessible_get_role(event.source, nil) + if role != C.ATSPI_ROLE_MENU && role != C.ATSPI_ROLE_POPUP_MENU { + return nil + } + current := adapter.nativeFocus[len(adapter.nativeFocus)-1].source + switch C.atspi_accessible_get_role(current, nil) { + case C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_CHECK_MENU_ITEM, C.ATSPI_ROLE_RADIO_MENU_ITEM: + default: + return nil + } + parent := C.atspi_accessible_get_parent(current, nil) + if parent == nil { + return nil + } + match := nativeObjectID(parent) == origin + C.g_object_unref(C.gpointer(parent)) + if !match { + return nil + } + return C.cthulhu_returned_menu_item(event.source) +} diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 43f6ce9..35f2b60 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -123,6 +123,8 @@ AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event); AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source); AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source); gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source); +gboolean cthulhu_is_table_container(AtspiAccessible *source); +AtspiAccessible *cthulhu_returned_menu_item(AtspiAccessible *menu); void cthulhu_refresh_frame_children(AtspiAccessible *window); AtspiAccessible *cthulhu_focused_descendant(AtspiAccessible *window); AtspiAccessible *cthulhu_showing_web_document(AtspiAccessible *window); diff --git a/internal/atspi/adapter_native_test.go b/internal/atspi/adapter_native_test.go index aaf1aea..06932d9 100644 --- a/internal/atspi/adapter_native_test.go +++ b/internal/atspi/adapter_native_test.go @@ -845,6 +845,9 @@ locked: if event.Source.Name != "Alpha" { t.Fatalf("unlock focus = %q, want Alpha", event.Source.Name) } + if event.WindowDescription != "Select an item and press Enter to open it." { + t.Fatalf("recovered window description = %q", event.WindowDescription) + } return } case <-ctx.Done(): diff --git a/internal/atspi/article_native_test.go b/internal/atspi/article_native_test.go new file mode 100644 index 0000000..0503fad --- /dev/null +++ b/internal/atspi/article_native_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +func TestNativeBrowserArticleContext(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stop, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := adapter.Stop(stop); err != nil { + t.Error(err) + } + }() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "F9").CombinedOutput(); err != nil { + t.Fatalf("focus: %s: %v", out, err) + } + for { + select { + case <-ctx.Done(): + t.Fatal("missing article control focus", ctx.Err()) + case event := <-adapter.Events(): + if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Name != "Reply" { + continue + } + for _, ancestor := range event.Source.SpeechContext { + if ancestor.Role == "article" && ancestor.Name == "Sample author A message with readable contents. Today at noon" { + return + } + } + t.Fatalf("message article missing from speech context: %+v", event.Source.SpeechContext) + } + } +} diff --git a/internal/atspi/panel_applet_native_test.go b/internal/atspi/panel_applet_native_test.go new file mode 100644 index 0000000..8735f29 --- /dev/null +++ b/internal/atspi/panel_applet_native_test.go @@ -0,0 +1,159 @@ +// 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" +) + +func TestNativeEmbeddedPanelApplet(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + // Embedding belongs to the window ancestry contract, not a process-name + // exception. Both MATE and an ordinary GTK host must retain ownership. + for _, hostName := range []string{"panel", "other-host"} { + t.Run(hostName, func(t *testing.T) { check_native_embedded_applet(t, hostName) }) + } +} + +func check_native_embedded_applet(t *testing.T, hostName string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + start := func(args ...string) (io.WriteCloser, string, string) { + t.Helper() + command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...) + 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() }) + line, err := bufio.NewReader(output).ReadString('\n') + fields := strings.Fields(line) + if err != nil || len(fields) != 3 || fields[0] != "ready" { + t.Fatalf("fixture readiness: %q, %v", line, err) + } + return input, fields[1], fields[2] + } + host, xid, socketID := start(hostName) + applet, _, _ := start("applet", socketID) + editor, editorXID, _ := start("editor") + wait := func(predicate func(Event) bool) Event { + t.Helper() + var last Event + for { + select { + case event := <-adapter.Events(): + last = event + if predicate(event) { + return event + } + case <-ctx.Done(): + t.Fatalf("native event missing; filtered=%d last=%s %q window=%+v mismatch=%v", adapter.backgroundEventsFiltered.Load(), last.Type, last.Source.Name, last.Window, last.ForegroundMismatch) + } + } + } + key := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool %v: %s, %v", args, output, err) + } + } + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + owned := wait(func(event Event) bool { + return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + icon := wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused }) + if icon.ForegroundMismatch || icon.Window != owned.Window || icon.Source.BusName == owned.Source.BusName || icon.Foreground != owned.Foreground { + t.Fatalf("embedded icon lost host ownership: host=%+v icon=%+v source=%+v mismatch=%v foreground=%+v want=%+v", owned.Window, icon.Window, icon.Source.ID(), icon.ForegroundMismatch, icon.Foreground, owned.Foreground) + } + if icon.Source.Name != "Network applet" || icon.Source.ApplicationProcess != "applet" { + t.Fatalf("embedded icon lost its label or provider identity: %+v", icon) + } + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "Applet settings" && event.Source.Focused && event.Window == owned.Window && !event.ForegroundMismatch + }) + key("key", "shift+Tab") + wait(func(event Event) bool { + return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "Applet settings" && event.Source.Focused && !event.ForegroundMismatch + }) + key("key", "Tab") + wait(func(event Event) bool { + return event.Source.Name == "After applet" && event.Source.Focused && !event.ForegroundMismatch + }) + for _, name := range []string{"Applet settings", "Network applet", "Before applet"} { + key("key", "shift+Tab") + wait(func(event Event) bool { + return event.Source.Name == name && event.Source.Focused && !event.ForegroundMismatch + }) + } + key("windowfocus", "--sync", editorXID) + fmt.Fprintln(editor, "edit Foreground restored") + wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch }) + fmt.Fprintln(applet, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("background applet stole foreground: %+v", event) + } + return event.Payload == "Applet barrier" + }) + // A separately published applet window cannot borrow the host's identity. + standalone, standaloneXID, _ := start("standalone") + key("windowfocus", "--sync", standaloneXID) + fmt.Fprintln(standalone, "first") + wait(func(event Event) bool { + return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch + }) + key("windowfocus", "--sync", xid) + fmt.Fprintln(host, "first") + wait(func(event Event) bool { + return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch + }) + fmt.Fprintln(standalone, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("unembedded applet borrowed host ownership: %+v", event) + } + return event.Payload == "Applet barrier" + }) + fmt.Fprintln(host, "detach") + wait(func(event Event) bool { return event.Payload == "Detached host barrier" }) + fmt.Fprintln(applet, "stale") + wait(func(event Event) bool { + if event.Source.Role == "icon" && !event.ForegroundMismatch { + t.Fatalf("detached applet retained host ownership: %+v", event) + } + return event.Payload == "Applet barrier" + }) +} diff --git a/internal/atspi/speech_metadata_native.go b/internal/atspi/speech_metadata_native.go index e7910e2..a64aa96 100644 --- a/internal/atspi/speech_metadata_native.go +++ b/internal/atspi/speech_metadata_native.go @@ -110,7 +110,7 @@ func native_speech_context(source *C.AtspiAccessible) ([]Snapshot, []Snapshot) { } if speechOpen { switch role { - case "block quote", "blockquote", "list", "list box", "table", "tree table", "panel", "grouping", "landmark", "form", "section", "document spreadsheet", "link": + case "article", "block quote", "blockquote", "list", "list box", "table", "tree table", "panel", "grouping", "landmark", "form", "section", "document spreadsheet", "link": if (role == "list" || role == "list box") && time.Now().Before(deadline) { var childError *C.GError count := C.atspi_accessible_get_child_count(current, &childError) diff --git a/internal/preferences/native.h b/internal/preferences/native.h index c65ea37..40ff5c0 100644 --- a/internal/preferences/native.h +++ b/internal/preferences/native.h @@ -2,6 +2,22 @@ // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later #include +// Shared action IDs for the native callbacks and Go preferences dispatcher. +// Value 10 is reserved; preserve the existing wire values. +enum { + PREFS_TENTACLE_REPOSITORY_ADD = 9, + PREFS_TENTACLE_TOGGLE = 11, + PREFS_TENTACLE_REPOSITORY_REMOVE = 12, + PREFS_TENTACLE_RETRY = 13, + PREFS_TENTACLE_REMOVE = 14, + PREFS_TENTACLE_REFRESH = 15, + PREFS_TENTACLE_SELECT = 16, + PREFS_TENTACLE_REPOSITORY_SELECT = 17, + PREFS_TENTACLE_REPOSITORY_REFRESH = 18, + PREFS_TENTACLE_INSTALL = 19, + PREFS_TENTACLE_SETTINGS = 20, + PREFS_TENTACLE_UPDATE = 21, +}; int prefs_init(const char *xml); void prefs_set_application(const char *name); void prefs_iterate(void); diff --git a/internal/preferences/native_tentacles.c b/internal/preferences/native_tentacles.c index 129ee2d..e084047 100644 --- a/internal/preferences/native_tentacles.c +++ b/internal/preferences/native_tentacles.c @@ -22,7 +22,7 @@ static void name_toggle_cells(void) { } static void tentacle_selected(GtkTreeSelection *selection, gpointer data) { (void)selection; (void)data; - if (!updating) prefs_set_action(16); + if (!updating) prefs_set_action(PREFS_TENTACLE_SELECT); } static void tentacle_clicked(GtkButton *button, gpointer data) { (void)button; @@ -36,12 +36,12 @@ static void tentacle_toggled(GtkCellRendererToggle *renderer, gchar *pathString, gtk_tree_path_free(path); // The accepted backend snapshot owns the check state; never check // optimistically before persistence or after an operation has failed. - prefs_set_action(11); + prefs_set_action(PREFS_TENTACLE_TOGGLE); } static gboolean tentacle_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { (void)widget; (void)data; if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE; - prefs_set_action(11); + prefs_set_action(PREFS_TENTACLE_TOGGLE); return TRUE; } static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint index, gpointer data) { @@ -54,7 +54,7 @@ static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint in gtk_container_add(GTK_CONTAINER(page), scroll); g_object_unref(scroll); } - if (!updating) prefs_set_action(17); + if (!updating) prefs_set_action(PREFS_TENTACLE_REPOSITORY_SELECT); } void prefs_init_tentacles(void) { updating = FALSE; @@ -74,9 +74,15 @@ void prefs_init_tentacles(void) { g_signal_connect(tree, "key-press-event", G_CALLBACK(tentacle_key), NULL); g_signal_connect(prefs_widget("tentacleRepositories"), "switch-page", G_CALLBACK(repository_switched), NULL); const struct { const char *id; int action; } buttons[] = { - {"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14}, - {"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18}, - {"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20}, + {"tentacleNew", PREFS_TENTACLE_REPOSITORY_ADD}, + {"tentacleRetry", PREFS_TENTACLE_RETRY}, + {"tentacleRemove", PREFS_TENTACLE_REMOVE}, + {"tentacleRefresh", PREFS_TENTACLE_REFRESH}, + {"tentacleRepositoryRemove", PREFS_TENTACLE_REPOSITORY_REMOVE}, + {"tentacleRepositoryRefresh", PREFS_TENTACLE_REPOSITORY_REFRESH}, + {"tentacleInstall", PREFS_TENTACLE_INSTALL}, + {"tentacleUpdate", PREFS_TENTACLE_UPDATE}, + {"tentacleSettings", PREFS_TENTACLE_SETTINGS}, }; for (unsigned i = 0; i < G_N_ELEMENTS(buttons); i++) g_signal_connect(prefs_widget(buttons[i].id), "clicked", G_CALLBACK(tentacle_clicked), GINT_TO_POINTER(buttons[i].action)); diff --git a/internal/preferences/tentacle_actions.go b/internal/preferences/tentacle_actions.go new file mode 100644 index 0000000..bcfedcc --- /dev/null +++ b/internal/preferences/tentacle_actions.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) { + if action == C.PREFS_TENTACLE_SELECT { + if selected := page.selected(); selected != nil { + page.selectedKey = selected.key + } + page.controls() + return nil, nil + } + if action == C.PREFS_TENTACLE_REPOSITORY_SELECT { + page.render() + return nil, nil + } + if action == C.PREFS_TENTACLE_REFRESH { + return &tentacle.ManagementRequest{Action: "list"}, nil + } + if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" { + message := "Tentacle management is unavailable for this run." + if page.snapshot != nil && page.snapshot.Message != "" { + message = page.snapshot.Message + } + return nil, errors.New(message) + } + if action == C.PREFS_TENTACLE_REPOSITORY_ADD { + id := C.CString("tentacleRepositoryURL") + origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id))) + C.free(unsafe.Pointer(id)) + if err != nil { + return nil, err + } + return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil + } + if action == C.PREFS_TENTACLE_REPOSITORY_REMOVE || action == C.PREFS_TENTACLE_REPOSITORY_REFRESH { + origin := page.origin() + if origin == "" { + return nil, errors.New("Select a repository tab first.") + } + operation := "repository-remove" + if action == C.PREFS_TENTACLE_REPOSITORY_REFRESH { + operation = "repository-refresh" + } + return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil + } + selected := page.selected() + if selected == nil { + return nil, errors.New("Select a tentacle first.") + } + page.selectedKey = selected.key + if action == C.PREFS_TENTACLE_UPDATE { + return page.updatePackage(selected) + } + if !selected.installed { + return page.installPackage(selected, action) + } + operation := "" + switch action { + case C.PREFS_TENTACLE_TOGGLE: + operation = "enable" + if selected.status.Entry.Enabled { + operation = "disable" + } + case C.PREFS_TENTACLE_RETRY: + operation = "retry" + case C.PREFS_TENTACLE_REMOVE: + operation = "remove" + case C.PREFS_TENTACLE_SETTINGS: + operation = "settings" + default: + return nil, errors.New("Unknown tentacle action.") + } + return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil +} + +func (page *tentaclePage) updatePackage(selected *tentacleRow) (*tentacle.ManagementRequest, error) { + if !selected.installed { + return nil, errors.New("Install this tentacle first.") + } + entry := selected.status.Entry + for _, update := range page.snapshot.Updates { + if update.ID != entry.ID { + continue + } + manifest := findTentacleManifest(page.snapshot, update.RepositoryURL, update.RepositoryID, update.LocalID) + if manifest == nil { + return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.") + } + message := tentacleUpdateConfirmation(entry, update, *manifest) + if !confirmTentaclePackage(message, "Update") { + return nil, nil + } + return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil + } + return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.") +} + +func (page *tentaclePage) installPackage(selected *tentacleRow, action int) (*tentacle.ManagementRequest, error) { + if action != C.PREFS_TENTACLE_TOGGLE && action != C.PREFS_TENTACLE_INSTALL { + return nil, errors.New("Install this tentacle first.") + } + entry := selected.status.Entry + manifest := findTentacleManifest(page.snapshot, entry.Repository, entry.RepositoryID, entry.LocalID) + if manifest == nil || len(manifest.Releases) == 0 { + return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.") + } + operation, label := "install", "Install" + if action == C.PREFS_TENTACLE_TOGGLE { + operation, label = "install-enable", "Install and enable" + } + message := tentacleInstallConfirmation(entry, *manifest, label) + if !confirmTentaclePackage(message, label) { + return nil, nil + } + return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil +} + +func confirmTentaclePackage(message, label string) bool { + text, button := C.CString(message), C.CString(label) + defer C.free(unsafe.Pointer(text)) + defer C.free(unsafe.Pointer(button)) + return C.prefs_tentacle_confirm(text, button) != 0 +} + +// Snapshot repositories have unique identities and local package IDs. +func findTentacleManifest(snapshot *tentacle.Snapshot, origin, repositoryID, localID string) *tentacle.Manifest { + for _, repository := range snapshot.Repositories { + if repository.URL != origin || repository.ID != repositoryID { + continue + } + for index := range repository.Tentacles { + if repository.Tentacles[index].ID == localID { + return &repository.Tentacles[index] + } + } + } + return nil +} diff --git a/internal/preferences/tentacle_confirmation.go b/internal/preferences/tentacle_confirmation.go new file mode 100644 index 0000000..b3692fe --- /dev/null +++ b/internal/preferences/tentacle_confirmation.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "fmt" + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func tentacleUpdateCapabilities(entry tentacle.Entry, manifest tentacle.Manifest) string { + var previous []string + if entry.Presentation { + previous = append(previous, "presentation") + } + if entry.Interaction { + previous = append(previous, "interaction") + } + if entry.ReaderSettings { + previous = append(previous, "reader-settings") + } + if len(previous) == 0 { + previous = append(previous, "none") + } + declared := manifest.Capabilities + if len(declared) == 0 { + declared = []string{"none"} + } + return "Current capabilities: " + strings.Join(previous, ", ") + + ". Repository declares: " + strings.Join(declared, ", ") + "." +} + +func tentacleInstallConfirmation(entry tentacle.Entry, manifest tentacle.Manifest, label string) string { + permissions := "This tentacle can register reader commands." + if slices.Contains(manifest.Capabilities, "presentation") { + permissions = "This tentacle can register reader commands and send speech and braille messages." + } + if slices.Contains(manifest.Capabilities, "interaction") { + permissions = "This tentacle can register reader commands, send speech and braille messages, open internal forms, read the focused item and clipboard when invoked, and write clipboard text." + } + if slices.Contains(manifest.Capabilities, "reader-settings") { + permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration." + } + return fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions) +} + +func tentacleUpdateConfirmation(entry tentacle.Entry, update tentacle.AvailableUpdate, manifest tentacle.Manifest) string { + permissions := tentacleUpdateCapabilities(entry, manifest) + return fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions) +} diff --git a/internal/preferences/tentacle_confirmation_test.go b/internal/preferences/tentacle_confirmation_test.go new file mode 100644 index 0000000..e4e4e62 --- /dev/null +++ b/internal/preferences/tentacle_confirmation_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func TestTentacleUpdateConfirmationDescribesPermissionChanges(t *testing.T) { + entry := tentacle.Entry{Presentation: true} + update := tentacle.AvailableUpdate{Name: "Example", InstalledVersion: "1.0", AvailableVersion: "1.1"} + manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}} + message := tentacleUpdateConfirmation(entry, update, manifest) + for _, want := range []string{ + "Update Example from version 1.0 to version 1.1?", + "Current capabilities: presentation.", + "Repository declares: presentation, interaction, reader-settings.", + "Changes take effect immediately.", + } { + if !strings.Contains(message, want) { + t.Fatalf("confirmation missing %q: %s", want, message) + } + } + message = tentacleUpdateConfirmation(tentacle.Entry{}, update, tentacle.Manifest{}) + if !strings.Contains(message, "Current capabilities: none. Repository declares: none.") { + t.Fatalf("empty capability sets were not described: %s", message) + } +} + +func TestTentacleInstallConfirmationDescribesInteractionAndSettings(t *testing.T) { + entry := tentacle.Entry{Name: "Example", Version: "1.1", Repository: "https://example.org/tentacles"} + manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}} + message := tentacleInstallConfirmation(entry, manifest, "Install and enable") + for _, want := range []string{ + "Install and enable Example version 1.1 from https://example.org/tentacles?", + "read the focused item and clipboard when invoked", + "temporarily change the reader voice configuration", + "resource limits do not protect your files or network", + } { + if !strings.Contains(message, want) { + t.Fatalf("confirmation missing %q: %s", want, message) + } + } + message = tentacleInstallConfirmation(entry, tentacle.Manifest{}, "Install") + if !strings.Contains(message, "This tentacle can register reader commands.") || strings.Contains(message, "write clipboard text") { + t.Fatalf("command-only installation described incorrectly: %s", message) + } +} diff --git a/internal/preferences/tentacles.go b/internal/preferences/tentacles.go index 8832113..30a4a1a 100644 --- a/internal/preferences/tentacles.go +++ b/internal/preferences/tentacles.go @@ -10,10 +10,7 @@ package preferences import "C" import ( - "errors" - "fmt" "reflect" - "slices" "strings" "unsafe" @@ -165,171 +162,6 @@ func (page *tentaclePage) populate(snapshot *tentacle.Snapshot) { page.render() } -func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) { - if action == 16 { - if selected := page.selected(); selected != nil { - page.selectedKey = selected.key - } - page.controls() - return nil, nil - } - if action == 17 { - page.render() - return nil, nil - } - if action == 15 { - return &tentacle.ManagementRequest{Action: "list"}, nil - } - if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" { - message := "Tentacle management is unavailable for this run." - if page.snapshot != nil && page.snapshot.Message != "" { - message = page.snapshot.Message - } - return nil, errors.New(message) - } - if action == 9 { - id := C.CString("tentacleRepositoryURL") - origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id))) - C.free(unsafe.Pointer(id)) - if err != nil { - return nil, err - } - return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil - } - if action == 12 || action == 18 { - origin := page.origin() - if origin == "" { - return nil, errors.New("Select a repository tab first.") - } - operation := "repository-remove" - if action == 18 { - operation = "repository-refresh" - } - return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil - } - selected := page.selected() - if selected == nil { - return nil, errors.New("Select a tentacle first.") - } - page.selectedKey = selected.key - if action == 21 { - if !selected.installed { - return nil, errors.New("Install this tentacle first.") - } - entry := selected.status.Entry - for _, update := range page.snapshot.Updates { - if update.ID != entry.ID { - continue - } - permissions := "" - foundManifest := false - for _, repository := range page.snapshot.Repositories { - if repository.URL != update.RepositoryURL || repository.ID != update.RepositoryID { - continue - } - for _, manifest := range repository.Tentacles { - if manifest.ID != update.LocalID { - continue - } - foundManifest = true - var previous []string - if entry.Presentation { - previous = append(previous, "presentation") - } - if entry.Interaction { - previous = append(previous, "interaction") - } - if entry.ReaderSettings { - previous = append(previous, "reader-settings") - } - if len(previous) == 0 { - previous = append(previous, "none") - } - declared := manifest.Capabilities - if len(declared) == 0 { - declared = []string{"none"} - } - permissions = "Current capabilities: " + strings.Join(previous, ", ") + - ". Repository declares: " + strings.Join(declared, ", ") + "." - } - } - if !foundManifest { - return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.") - } - message := fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions) - text, button := C.CString(message), C.CString("Update") - accepted := C.prefs_tentacle_confirm(text, button) - C.free(unsafe.Pointer(text)) - C.free(unsafe.Pointer(button)) - if accepted == 0 { - return nil, nil - } - return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil - } - return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.") - } - if !selected.installed { - if action != 11 && action != 19 { - return nil, errors.New("Install this tentacle first.") - } - entry := selected.status.Entry - var manifest *tentacle.Manifest - for _, repository := range page.snapshot.Repositories { - if repository.URL == entry.Repository && repository.ID == entry.RepositoryID { - for index := range repository.Tentacles { - if repository.Tentacles[index].ID == entry.LocalID { - manifest = &repository.Tentacles[index] - break - } - } - } - } - if manifest == nil || len(manifest.Releases) == 0 { - return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.") - } - permissions := "This tentacle can register reader commands." - if slices.Contains(manifest.Capabilities, "presentation") { - permissions = "This tentacle can register reader commands and send speech and braille messages." - } - if slices.Contains(manifest.Capabilities, "interaction") { - permissions = "This tentacle can register reader commands, send speech and braille messages, open internal forms, read the focused item and clipboard when invoked, and write clipboard text." - } - if slices.Contains(manifest.Capabilities, "reader-settings") { - permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration." - } - operation, label := "install", "Install" - if action == 11 { - operation, label = "install-enable", "Install and enable" - } - message := fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions) - text, button := C.CString(message), C.CString(label) - accepted := C.prefs_tentacle_confirm(text, button) - C.free(unsafe.Pointer(text)) - C.free(unsafe.Pointer(button)) - if accepted == 0 { - return nil, nil - } - return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil - } - operation := "" - switch action { - case 11: - operation = "enable" - if selected.status.Entry.Enabled { - operation = "disable" - } - case 13: - operation = "retry" - case 14: - operation = "remove" - case 20: - operation = "settings" - default: - return nil, errors.New("Unknown tentacle action.") - } - return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil -} - func (page *tentaclePage) accepted(request *tentacle.ManagementRequest) { if request.Action == "repository-add" { id, empty := C.CString("tentacleRepositoryURL"), C.CString("") diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index c7a4568..0ec3fcf 100644 --- a/internal/preferences/ui.go +++ b/internal/preferences/ui.go @@ -146,7 +146,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { session.refreshSpeechVoices() session.refreshKeyBindingLayout() action := C.prefs_action() - if action >= 9 && action <= 21 && !busy { + if action >= C.PREFS_TENTACLE_REPOSITORY_ADD && action <= C.PREFS_TENTACLE_UPDATE && !busy { request, err := tentacles.action(int(action)) if err != nil { status(err.Error(), true) diff --git a/internal/presentation/reading.go b/internal/presentation/reading.go index cfc9f14..893769a 100644 --- a/internal/presentation/reading.go +++ b/internal/presentation/reading.go @@ -69,6 +69,10 @@ func FullRowEnabled(snapshot atspi.Snapshot, options Options) bool { // ContextType matches Python's separate container and landmark preferences. func ContextType(snapshot atspi.Snapshot) string { switch snapshot.Role { + case "article": + if snapshot.Name != "" { + return "article" + } case "block quote", "blockquote": return "blockquote" case "list", "list box": @@ -109,6 +113,8 @@ func ContextEnabled(kind string, reading config.ReadingSettings) bool { return false } switch kind { + case "article": + return true case "blockquote": return reading.SpeakContextBlockquote case "list": @@ -131,6 +137,8 @@ func SayAllContextEnabled(kind string, reading config.ReadingSettings) bool { return false } switch kind { + case "article": + return true case "blockquote": return reading.SayAllContextBlockquote case "list": diff --git a/internal/wine/helper_staging.go b/internal/wine/helper_staging.go new file mode 100644 index 0000000..0b6d2d0 --- /dev/null +++ b/internal/wine/helper_staging.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wine + +import ( + "bytes" + "crypto/sha256" + "fmt" + "os" + "path/filepath" +) + +// A helperSet is a launch-local snapshot of the complete installed helper. +// Publishing and verification use the same bytes and modes that form its digest. +type helperSet struct { + files []helperFile + digest string +} + +type helperFile struct { + name string + data []byte + mode os.FileMode +} + +// A pressure-vessel wineserver cannot see the host's /usr. Publish complete, +// immutable helper sets in the home-mounted state directory. Never overwrite +// an older set: running processes may still have its sidecar mapped. +func stage_helper(helper string, environment map[string]string) (string, error) { + if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present { + return helper, nil + } + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + stateHome = filepath.Join(home, ".local", "state") + } + if !filepath.IsAbs(stateHome) { + return "", fmt.Errorf("Wine helper state directory must be absolute") + } + set, err := readHelperSet(helper) + if err != nil { + return "", err + } + stage, err := set.publish(filepath.Join(stateHome, "cthulhu", "wine-access", "helper")) + if err != nil { + return "", err + } + return filepath.Join(stage, filepath.Base(helper)), nil +} + +func readHelperSet(helper string) (*helperSet, error) { + names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"} + set := &helperSet{} + digest := sha256.New() + for _, name := range names { + path := filepath.Join(filepath.Dir(helper), name) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read helper set: %w", err) + } + info, err := os.Stat(path) + if err != nil { + return nil, err + } + file := helperFile{name: name, data: data, mode: 0600} + if info.Mode().Perm()&0111 != 0 { + file.mode = 0700 + } + fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), file.mode) + digest.Write(data) + set.files = append(set.files, file) + } + set.digest = fmt.Sprintf("%x", digest.Sum(nil)) + return set, nil +} + +func (set *helperSet) publish(root string) (string, error) { + if err := os.MkdirAll(root, 0700); err != nil { + return "", err + } + stage := filepath.Join(root, set.digest) + if _, err := os.Stat(stage); os.IsNotExist(err) { + temporary, err := os.MkdirTemp(root, ".stage-") + if err != nil { + return "", err + } + defer os.RemoveAll(temporary) + for _, file := range set.files { + if err := os.WriteFile(filepath.Join(temporary, file.name), file.data, file.mode); err != nil { + return "", err + } + } + if err := os.Rename(temporary, stage); err != nil { + if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() { + return "", err + } + } + } + if err := set.verify(stage); err != nil { + return "", err + } + return stage, nil +} + +func (set *helperSet) verify(stage string) error { + for _, file := range set.files { + path := filepath.Join(stage, file.name) + data, err := os.ReadFile(path) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !bytes.Equal(data, file.data) || info.Mode().Perm() != file.mode { + return fmt.Errorf("incomplete or modified staged Wine helper") + } + } + return nil +} diff --git a/internal/wine/proton.go b/internal/wine/proton.go index 3329ffb..d64045c 100644 --- a/internal/wine/proton.go +++ b/internal/wine/proton.go @@ -5,8 +5,6 @@ package wine import ( "bytes" - "crypto/sha256" - "fmt" "os" "path/filepath" "sort" @@ -104,81 +102,3 @@ func helper_arguments(controllerOnly bool, settings Settings) []string { } return arguments } - -// A pressure-vessel wineserver cannot see the host's /usr. Publish complete, -// immutable helper sets in the home-mounted state directory. Never overwrite -// an older set: running processes may still have its sidecar mapped. -func stage_helper(helper string, environment map[string]string) (string, error) { - if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present { - return helper, nil - } - stateHome := os.Getenv("XDG_STATE_HOME") - if stateHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - stateHome = filepath.Join(home, ".local", "state") - } - if !filepath.IsAbs(stateHome) { - return "", fmt.Errorf("Wine helper state directory must be absolute") - } - names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"} - contents := make(map[string][]byte) - modes := make(map[string]os.FileMode) - digest := sha256.New() - for _, name := range names { - path := filepath.Join(filepath.Dir(helper), name) - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read helper set: %w", err) - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - contents[name], modes[name] = data, 0600 - if info.Mode().Perm()&0111 != 0 { - modes[name] = 0700 - } - fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), modes[name]) - digest.Write(data) - } - root := filepath.Join(stateHome, "cthulhu", "wine-access", "helper") - if err := os.MkdirAll(root, 0700); err != nil { - return "", err - } - stage := filepath.Join(root, fmt.Sprintf("%x", digest.Sum(nil))) - if _, err := os.Stat(stage); os.IsNotExist(err) { - temporary, err := os.MkdirTemp(root, ".stage-") - if err != nil { - return "", err - } - defer os.RemoveAll(temporary) - for _, name := range names { - if err := os.WriteFile(filepath.Join(temporary, name), contents[name], modes[name]); err != nil { - return "", err - } - } - if err := os.Rename(temporary, stage); err != nil { - if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() { - return "", err - } - } - } - for _, name := range names { - path := filepath.Join(stage, name) - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - if !bytes.Equal(data, contents[name]) || info.Mode().Perm() != modes[name] { - return "", fmt.Errorf("incomplete or modified staged Wine helper") - } - } - return filepath.Join(stage, filepath.Base(helper)), nil -} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 70690f2..d6864f6 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -1,4 +1,5 @@ id,python_source,subsystem,behavior,owner,go_destination,status,automated_evidence,x11_evidence,wayland_evidence,speech_evidence,braille_evidence,notes +embedded-panel-applet,Orca src/orca/script_utilities.py:frame_and_dialog;top_level_object;src/orca/scripts/default.py:_on_focused_changed,focus,Follow embedded GTK applet focus through the socket host window while preserving the source provider,,internal/atspi internal/app,in-progress,Private native regression reproduces rejection before the fix; socket and plug tests cover MATE and ordinary hosts; see docs/testing/mate-desktop.md,Separate host/applet/editor providers cover icon labels; Tab and Shift+Tab; editor return; background and unrelated providers; socket detachment.,,Queued speech verified; real MATE listening pending.,Native event-loop frames verified; physical Braille acceptance pending.,Refresh parent links through embedded frames before normal X11 host validation. No process-name bypass or retained embedding state. Human review and live panel traversal required. submission-clear-deletion,src/cthulhu/scripts/default.py:onTextDeleted,speech,Avoid announcing an entry clear after plain Enter before the submitted chat message,,internal/app/submitted_text.go;internal/input/intent.go,in-progress,Submission replay fails against the original presenter and passes with the fix; editing regression matrix and focused race checks pass.,Private GTK submission and selected-text replacement checks use native snapshots and the real X11 input listener.,,Speech requests verified; live Pidgin listening pending.,Braille update path retained; physical display acceptance pending.,Requires focused text or entry plus fresh plain Enter and known empty line at offset zero. Selection replacement and modified Enter retain existing feedback. No Thunderbird or word-echo changes. See docs/testing/messaging.md. mate-panel-menu,Orca src/orca/scripts/default.py;src/orca/ax_utilities.py;src/orca/generator.py,focus,Read active MATE panel menus across X11 application boundaries and label unnamed panel icons,,internal/atspi internal/app,in-progress,Private native and event-loop regressions; see docs/testing/mate-desktop.md,Separate GTK providers cover editor and empty desktop menus; submenus; reverse navigation; Escape; hidden/impostor rejection; icon label precedence.,,Queued presentation verified; actual Fedora MATE listening pending.,Native event-loop frames verified; physical display acceptance pending.,Fresh active/showing native menu structure and verified mate-panel process only. Preserves X11 correlation without retained popup state. Existing Marco and notification paths retained. Human review and live MATE acceptance required. mate-window-switcher,Orca src/orca/scripts/switcher/script.py;src/orca/scripts/switcher/script_utilities.py,focus,Present Marco switcher selections while the old application retains X11 foreground,,internal/atspi internal/app,in-progress,go test ./internal/app -run '^TestMateWindowSwitcher' -count=1; see docs/testing/window-switcher.md,Private GTK provider checks repeated insertions and showing events outside foreground without taking ownership.,,Actual Marco listening and physical key acceptance pending.,Transient flash requests tested; physical display acceptance pending.,Verified Marco process and application plus visible status-bar container only. Preserves application focus and review. No name-signal duplication or general background bypass. Original log lacks later cycling insertions; real cycling and Escape remain live gates. @@ -18,7 +19,7 @@ xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu whi 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,implemented,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 confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,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. Current general native focus listening is accepted by the maintainer on 2026-10-22. terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,implemented,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 confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22. terminal-text-change-echo,src/cthulhu/scripts/terminal/script.py,terminal,Speak sanitized inserted or deleted text from terminals and editable text controls,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,"Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is pending.",,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,"Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending." -managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. +managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending. ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending. web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,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.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,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.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass. @@ -72,3 +73,4 @@ mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/ speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,implemented,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile. pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md." atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md." +web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py,web,Announce named message articles when focus enters their controls,,internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go,in-progress,Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md.,Private Chromium fixture uses isolated X11 and D-Bus.,,Recorded speech only; live Discord listening pending.,Physical display acceptance pending.,Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit. diff --git a/tests/browser-article.html b/tests/browser-article.html new file mode 100644 index 0000000..bb1a1a8 --- /dev/null +++ b/tests/browser-article.html @@ -0,0 +1,11 @@ + + +Cthulhu Browser Probe +
    +
  1. +

    +
    A message with readable contents.
    + +
  2. +
+ diff --git a/tests/gtk_panel_applet.py b/tests/gtk_panel_applet.py new file mode 100644 index 0000000..128186d --- /dev/null +++ b/tests/gtk_panel_applet.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Real GTK socket/plug embedding on separate providers for private tests.""" +import ctypes +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 + + +mode = sys.argv[1] +processName = "mate-panel" if mode == "panel" else "applet" if mode == "standalone" else mode +GLib.set_prgname(processName) +if ctypes.CDLL(None).prctl(15, processName.encode(), 0, 0, 0) != 0: + raise RuntimeError("cannot set fixture process name") +window = Gtk.Plug.new(int(sys.argv[2])) if mode == "applet" else Gtk.Window() +if mode == "applet": + window.connect("delete-event", lambda *_args: True) +if mode == "standalone": + Atk.get_root().set_name("mate-panel") +window.set_title("Embedded applet fixture " + mode) +window.set_default_size(400, 120) +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +window.add(box) +socket = None +if mode in ("applet", "standalone"): + box.get_accessible().set_role(Atk.Role.PANEL) + box.get_accessible().set_name("Network applet") + first = Gtk.Button(label="Network") + first.get_accessible().set_role(Atk.Role.ICON) + first.get_accessible().set_name("") + second = Gtk.Button(label="Applet settings") + box.add(first) + box.add(second) +elif mode == "editor": + first = Gtk.Entry() + first.get_accessible().set_name("Editor entry") + box.add(first) +else: + first = Gtk.Button(label="Before applet") + second = Gtk.Button(label="After applet") + socket = Gtk.Socket() + box.add(first) + box.add(socket) + box.add(second) +barrier = Gtk.Label() +barrier.get_accessible().set_role(Atk.Role.ALERT) +box.add(barrier) +window.show_all() +pendingInput = "" + + +def receive_command(_source, _condition): + global pendingInput + 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 == "first": + first.grab_focus() + elif command == "second": + second.grab_focus() + elif command == "stale": + first.get_accessible().notify_state_change(Atk.StateType.FOCUSED, True) + barrier.set_text("") + barrier.set_text("Applet barrier") + elif command == "detach": + socket.destroy() + barrier.set_text("Detached host barrier") + elif command.startswith("edit "): + first.set_text(command.removeprefix("edit ")) + first.grab_focus() + 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()), + socket.get_id() if socket is not None else 0, flush=True) or False) +Gtk.main() diff --git a/tests/gtk_window_switcher.py b/tests/gtk_window_switcher.py index 07c2d4d..281d946 100644 --- a/tests/gtk_window_switcher.py +++ b/tests/gtk_window_switcher.py @@ -24,6 +24,9 @@ if providerName == "marco": raise RuntimeError("cannot set fixture process name") window = Gtk.Window(title="Switcher fixture " + providerName) +# Label updates must not resize the foreground window while the test compares +# its full X11 snapshot to check that the switcher has not taken ownership. +window.set_default_size(400, 100) if providerName == "marco": window.set_accept_focus(False) window.set_focus_on_map(False) diff --git a/tests/native_audacious.py b/tests/native_audacious.py new file mode 100644 index 0000000..6af51c7 --- /dev/null +++ b/tests/native_audacious.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Exercise real Audacious GUIs without the desktop player's session or files.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import wave + +sys.dont_write_bytecode = True +from native_test_session import run_private_session +from native_x11 import wait_for + + +def run_inside(): + root = Path(__file__).resolve().parents[1] + temporary = Path(os.environ['XDG_CONFIG_HOME']) + configPath = temporary / 'i3.conf' + configPath.write_text('font pango:monospace 10\nfocus_follows_mouse no\nipc-socket ' + + str(temporary / 'i3.sock') + '\n', encoding='utf-8') + manager = subprocess.Popen(['i3', '-c', str(configPath)], stdout=subprocess.DEVNULL) + process = None + try: + wait_for(lambda: subprocess.run(['i3-msg', '-t', 'get_version'], + capture_output=True).returncode == 0, 'private i3 missing') + tracks = [] + trackDirectory = temporary / 'Example Artist' / 'Example Album' + trackDirectory.mkdir(parents=True) + for name in ('Alpha', 'Beta', 'Gamma'): + path = trackDirectory / (name + '.wav') + with wave.open(str(path), 'wb') as track: + track.setparams((1, 2, 8000, 0, 'NONE', 'not compressed')) + track.writeframes(b'\0\0' * 8000) + tracks.append(str(path)) + process = subprocess.Popen(['audacious', '-2', os.environ['CTHULHU_NATIVE_AUDACIOUS'], '-e', *tracks]) + + def find_window(): + result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^Audacious \\(2\\)$'], + capture_output=True, text=True) + return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None + + wait_for(find_window, 'Audacious window missing') + import pyatspi + + def find_playlist(node): + if node.getRoleName() in ('tree', 'table'): + table = node.queryTable() + if table.nRows == 3: + return table + for child in node: + result = find_playlist(child) + if result is not None: + return result + return None + + playlist = wait_for(lambda: find_playlist(pyatspi.Registry.getDesktop(0)), + 'Audacious playlist missing') + assert playlist.addRowSelection(0), 'could not select first fixture row' + if os.environ['CTHULHU_NATIVE_AUDACIOUS'] == '-G': + assert playlist.getAccessibleAt(0, 0).queryComponent().grabFocus(), 'could not focus first fixture cell' + # Selecting through AT-SPI does not initialize Audacious's GTK + # keyboard cursor. The first Down establishes its first row. + subprocess.run(['xdotool', 'key', '--clearmodifiers', 'Down'], check=True) + wait_for(lambda: playlist.getSelectedRows() == [0], 'GTK cursor did not select first row') + subprocess.run(['go', 'test', './internal/app', '-run', '^TestNativeAudacious$', + '-count=1', '-v', '-timeout=90s'], cwd=root, check=True, timeout=120) + finally: + if process is not None: + process.terminate() + process.wait(timeout=5) + manager.terminate() + manager.wait(timeout=5) + + +if __name__ == '__main__': + if '--diagnose' in sys.argv: + import pyatspi + def describe(node, depth=0): + if depth > 8: + return + if node.getRoleName() in ('tree', 'table', 'frame', 'table cell'): + print(node.getRoleName(), repr(node.name), node.getState().getStates(), flush=True) + if node.getRoleName() in ('tree', 'table'): + table = node.queryTable() + print('ROWS', table.getSelectedRows(), flush=True) + for row in range(table.nRows): + cell = table.getAccessibleAt(row, 0) + print('CELL', repr(cell.name), cell.getState().getStates(), flush=True) + for child in node: + describe(child, depth+1) + for app in pyatspi.Registry.getDesktop(0): + if app.name.lower() == 'audacious': + describe(app) + elif '--inside' in sys.argv: + run_inside() + else: + moduleCache = subprocess.check_output(['go', 'env', 'GOMODCACHE'], text=True).strip() + for mode in ('-Q', '-G'): + with tempfile.TemporaryDirectory(prefix='cthulhu-audacious-') as temporary: + environment = dict(os.environ, XDG_CONFIG_HOME=temporary, + XDG_CACHE_HOME=temporary, XDG_DATA_HOME=temporary, XDG_STATE_HOME=temporary, + GOMODCACHE=moduleCache, GOCACHE='/tmp/cthulhu-go-cache', + GOPROXY='off', QT_QPA_PLATFORM='xcb', + QT_LINUX_ACCESSIBILITY_ALWAYS_ON='1', NO_AT_BRIDGE='0', + CTHULHU_NATIVE_AUDACIOUS=mode) + environment['I3SOCK'] = str(Path(temporary) / 'i3.sock') + run_private_session(['xvfb-run', '-a', 'dbus-run-session', '--', + sys.executable, __file__, '--inside'], env=environment, check=True) diff --git a/tests/native_browser.py b/tests/native_browser.py index 63b2625..6e752ed 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session BROWSER_PROOFS = { + "article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"), "emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"), "structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50), "disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"), diff --git a/tests/native_qt_controls.py b/tests/native_qt_controls.py index 32d0919..f71570f 100644 --- a/tests/native_qt_controls.py +++ b/tests/native_qt_controls.py @@ -58,6 +58,8 @@ def fixture(version): option = menu.addAction('Optional feature') option.setCheckable(True) ordinary = menu.addAction('Ordinary action') + nestedMenu = menu.addMenu('Nested actions') + nestedMenu.addAction('Nested command') contextMenu = QtWidgets.QMenu(window) contextOption = contextMenu.addAction('Context option') contextOption.setCheckable(True) diff --git a/tests/native_tentacles.py b/tests/native_tentacles.py new file mode 100644 index 0000000..af5b66d --- /dev/null +++ b/tests/native_tentacles.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tentacle management action routing with fixture responses, no downloads.""" +import select + +from native_x11 import wait_for + + +def check_page(pyatspi, process, window, nodes, named, walk, focus, key, + request, send, initial, application): + tab = next(node for node in nodes + if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB) + assert tab.parent.querySelection().selectChild(tab.getIndexInParent()) + origin = "https://example.org/tentacles" + repository = {"URL": origin, "ID": "example.source", "Name": "Example", + "Tentacles": [{"ID": "echo", "Name": "Example package", "Version": "1.0", + "Capabilities": ["presentation"], "Releases": [{}]}]} + snapshot = {"Entries": [], "Repositories": [repository]} + table = next(node for node in walk(window) + if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE) + + def press(name): + button = named(name) + wait_for(lambda: button.getState().contains(pyatspi.STATE_SENSITIVE), name + " disabled") + focus(button) + key("space") + + status_node = named(initial["Message"]) + + def accept(action, package_id="", url=""): + value = request()["Tentacle"] + assert value == {"Action": action, "ID": package_id, "URL": url, "Entry": None}, value + message = "Accepted " + action + if action == "settings": + # Successful settings replies intentionally leave the status alone. + # Observe a changed snapshot cell as the response barrier instead. + snapshot["Entries"][0]["Message"] = message + send(dict(initial, Tentacles=snapshot, Message=message)) + if action == "settings": + wait_for(lambda: any(node.name == message for node in walk(table)), + "settings response was not rendered") + else: + wait_for(lambda: status_node.name == message, action + " response missing") + wait_for(lambda: named("Refresh status").getState().contains(pyatspi.STATE_SENSITIVE), + action + " left preferences busy") + + url_entry = next(node for node in walk(window) + if node.name == "Repository URL" and + node.getState().contains(pyatspi.STATE_EDITABLE)) + url_entry.queryEditableText().setTextContents(origin) + press("Add repository") + accept("repository-add", url=origin) + wait_for(lambda: table.queryTable().nRows == 1, "catalog row missing") + focus(table) + key("Home") + + def confirmation(): + for app in pyatspi.Registry.getDesktop(0): + for node in app: + if node and node.getRole() in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT): + if any("Only install packages" in child.name for child in walk(node)): + return node + return None + + def answer(label): + dialog = wait_for(confirmation, "install confirmation missing") + message = " ".join(node.name for node in walk(dialog)) + assert "Example package version 1.0" in message, message + assert "send speech and braille messages" in message, message + button = next(node for node in walk(dialog) + if node.name == label and node.getRole() == pyatspi.ROLE_PUSH_BUTTON) + focus(button) + key("space") + wait_for(lambda: not confirmation(), "confirmation did not close") + + press("Install") + answer("Cancel") + assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel submitted installation" + press("Install") + answer("Install") + entry = {"ID": "installed-echo", "LocalID": "echo", "Name": "Example package", + "Version": "1.0", "Repository": origin, "RepositoryID": "example.source", + "Presentation": True, "Enabled": False} + status = {"Entry": entry, "State": "disabled"} + snapshot["Entries"] = [status] + accept("install", "echo", origin) + focus(table) + key("Home", "space") + entry["Enabled"], status["State"] = True, "active" + accept("enable", entry["ID"]) + press("Retry") + accept("retry", entry["ID"]) + press("Settings") + accept("settings", entry["ID"]) + focus(table) + key("space") + entry["Enabled"], status["State"] = False, "disabled" + accept("disable", entry["ID"]) + press("Remove tentacle") + snapshot["Entries"] = [] + accept("remove", entry["ID"]) + press("Refresh status") + accept("list") + repositories = named("Tentacle repositories") + assert repositories.querySelection().selectChild(1), "repository tab missing" + press("Refresh repository") + accept("repository-refresh", url=origin) + press("Remove repository") + snapshot["Repositories"] = [] + accept("repository-remove", url=origin) + wait_for(lambda: table.queryTable().nRows == 0, "removed repository left rows") + press("Cancel") + assert process.wait(timeout=5) == 0 + print("PASS: repository actions, install confirmation/Cancel, enable/disable, retry, settings, remove, refresh")